diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 745798e24..4107bd5cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,19 @@ jobs: - name: 'Gate 4c: Anti-sludge shell checks (junk-drawer filenames)' run: npm run lint:sludge + type-firewall-path-hygiene: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + - name: Use Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: '22' + - name: 'Gate 4p: Exact committed-tree machine-path hygiene' + run: node scripts/check-machine-local-paths.ts --tree "$GITHUB_SHA" + type-firewall-semgrep: runs-on: ubuntu-latest steps: @@ -127,10 +140,9 @@ jobs: - name: 'Gate 5a: Install pinned Wesley generator' run: >- cargo install - --git https://github.com/flyingrobots/wesley.git - --rev 4891a631f888c5b2f70e117e3704538dd1362c2f - --locked wesley-cli + --version 0.3.0-alpha.1 + --locked - name: 'Gate 5b: Wesley vocabulary IR drift' run: npm run check:vocabulary-ir - name: 'Gate 5c: v19 capability artifact drift' @@ -184,6 +196,7 @@ jobs: needs: - type-firewall-types - type-firewall-lint + - type-firewall-path-hygiene - type-firewall-semgrep - type-firewall-quarantine - type-firewall-surface @@ -195,6 +208,7 @@ jobs: run: | echo "types: ${{ needs['type-firewall-types'].result }}" echo "lint: ${{ needs['type-firewall-lint'].result }}" + echo "paths: ${{ needs['type-firewall-path-hygiene'].result }}" echo "semgrep: ${{ needs['type-firewall-semgrep'].result }}" echo "quarantine: ${{ needs['type-firewall-quarantine'].result }}" echo "surface: ${{ needs['type-firewall-surface'].result }}" @@ -203,6 +217,7 @@ jobs: test "${{ needs['type-firewall-types'].result }}" = "success" test "${{ needs['type-firewall-lint'].result }}" = "success" + test "${{ needs['type-firewall-path-hygiene'].result }}" = "success" test "${{ needs['type-firewall-semgrep'].result }}" = "success" test "${{ needs['type-firewall-quarantine'].result }}" = "success" test "${{ needs['type-firewall-surface'].result }}" = "success" diff --git a/AGENTS.md b/AGENTS.md index ecfeff857..81f9828dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,10 @@ DTO and stop there**. Do not hallucinate fake domain models. - NEVER force any git operation. - NEVER use destructive cleanup or history rewrite commands like `git reset --hard`, `git clean -f`, `git checkout .`, or `git restore .`. - This repo stores graph data as Git commits; rewriting history can destroy user data. +- NEVER publish machine-local absolute paths. This applies to tracked files, + generated evidence, PR and issue bodies, comments, and reviews. Use + repository-relative paths, `~`, `$HOME`, or an explicit placeholder such as + ``. - At the end of each turn, stage only the specific files written in that turn. Do not use `git add -A` by default. - If you wrote files in the turn, commit them in that turn. Do not leave your own edits staged but uncommitted. - Cycle-start draft pull requests are allowed and expected. After the design diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ea06365..ce47fa3e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,160 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `intent.entity.add({ subject, properties })` creates one entity occurrence and + its initial payload in a single patch. The lowered patch declares an empty + read set and exactly one subject write. That declaration describes the + operands encoded by the patch; it does not prove that the caller made no + prior graph read to choose the subject or payload. An application-supplied + semantic subject may intentionally receive more than one occurrence. + Previously the only + way to create a node with properties was `node.add` followed by + `property.set`, which costs two patches and records a self-read on the payload + patch. +- `PatchBuilder.addEntity(nodeId, properties)` lowers that intent. It requires a + non-empty payload (`E_PATCH_ENTITY_EMPTY`) and refuses an id the builder can + already see (`E_PATCH_ENTITY_EXISTS`). +- `intent.entity.addAuto({ namespace, properties })` and the CLI's + `entity.add` namespace form allocate an opaque subject from the same + writer-local dot used by `NodeAdd`. Applications without an independent + semantic key no longer need to mint a timestamp/counter tuple or maintain a + shadow writer counter. +- Admitted entity `WriteReceipt`s carry an `EntityOccurrence` with the resolved + subject and opaque occurrence id. `relationTo` uses version-vector context for + `before`/`after`/`concurrent`; `compare` uses the canonical `EventId` order for + deterministic listing. Application timestamps remain payload metadata only. +- `intentFromPatch` recovers an entity capture from its persisted evidence: a + leading `NodeAdd`, property writes on that same node, no repeated key, and a + recorded footprint of no reads and exactly one write naming the created + subject. A patch that merely resembles the shape without declaring that + footprint is not recognised; it falls through to the one-operation rule and + fails hydration rather than being promoted to a stronger claim. + + Payload keys are ordered canonically at both ends, so two payloads differing + only in construction order produce identical operations. Property maps are + built with a null prototype, so a key such as `__proto__` stays ordinary data. + + Scope of these guarantees, stated because the shape is easy to over-read: + - **Initial payload, not complete entity.** Which fields make an entity + complete is an application schema concern; the substrate checks only that + properties exist. + - **Creation, not lifetime.** An auto-allocated subject's cone is a singleton + until something else writes the id. `property.set` and `node.remove` remain + available, so an immutable-entity lifetime is a law an application adopts, + not one this constructor imposes. + - **No uniqueness on the lane path.** `E_PATCH_ENTITY_EXISTS` fires only for + an id the builder can see: added earlier in the same patch, or alive in the + materialized basis it was opened against. A `Runtime` lane writer has + neither — nothing on `Runtime` materializes, and one intent lowers to one + patch — so the guard never fires there. Re-creating a subject is admitted + on one lane by one writer, across writers sharing a frontier, and by a + writer opening after the first creation is durable; the join merges them + into one entity with a multi-patch cone. The guard is a mistake-catcher for + a directly constructed `PatchBuilder` opened against a materialized state. + Supplied semantic-subject uniqueness remains the application's invariant; + substrate allocation is available when no such semantic key exists. Every + admitted addition still has a distinct substrate occurrence. + +### Changed + +- Repository lint now rejects personal-home and Darwin temporary absolute + paths in tracked or unignored text and binary files, and the pre-commit hook + inspects exact staged additions and modifications rather than mutable + working-tree bytes. The pre-push hook inspects every outgoing Git object, so a + later safe branch tip cannot conceal an earlier leaking blob or commit. + A dedicated required CI lane independently inspects the exact commit tree + before merge. Contributor doctrine also forbids publishing machine-local paths + in generated evidence, issues, pull requests, comments, or reviews. +- Node and edge content attachment share one staging helper, so the asset + storage precondition is stated once instead of duplicated per target shape. +- Effect id validation and derivation moved to `PatchBuilderValidation`. +- Permissive `PatchBuilder` effect and property values now use method type + parameters instead of leaking boundary-level `unknown` into the domain API. + Runtime validation and accepted JavaScript inputs are unchanged. + +### Fixed + +- Content attachment now rechecks the builder lifecycle after asynchronous + asset staging. Publication that overtakes staging can no longer be followed + by late property operations or attachment handles on an already committed + patch. +- Live strand settlement now replays the canonical Intent recovered from the + published draft patch. An auto-allocated entity therefore keeps the subject + named by its write receipt, matching settlement after a Runtime reopen. +- Entity occurrence identity and ordering now include their worldline scope. + Equal writer dots in independent worldlines are concurrent instead of being + misreported as the same occurrence; cross-worldline lists order the worldline + before applying canonical `EventId` order. +- Dots and version-vector counters now reject integers beyond JavaScript's exact + range. `Dot.decode` also rejects counter spellings that `Dot.encode` cannot + produce, so suffixes, decimal points, exponents, signs, leading zeroes, and + whitespace cannot alias a canonical Dot. Counter exhaustion fails before + mutation instead of reissuing a writer Dot and colliding an auto-allocated + entity subject. +- Version-vector serialization now preserves prototype-named writers such as + `__proto__` as own data properties instead of invoking inherited object + setters and silently dropping their causal coordinates. +- `PatchBuilder.addEntity` now enforces the committed-builder lifecycle before + reading snapshot state or validating entity input, matching every other + builder mutation. +- Admitted entity receipts now require the occurrence-owned causal coordinate, + bound to the exact Intent, causal Evidence, lane, and writer for which it was + issued. The runtime adapter retains no ambient occurrence registry, and the + domain object owns its comparison and relation behavior instead of accepting + injected callbacks. Neither an arbitrary `EntityOccurrence` instance nor a + genuine occurrence transplanted from another receipt can forge authoritative + receipt identity. Receipt binding records that public writer separately from + the Dot/EventId writer, so a strand overlay remains a valid causal coordinate + without impersonating the receipt writer. Evidence canonicalization is + structurally idempotent without a process-local membership registry, and + occurrence validation uses the exact frozen Evidence exposed by the receipt, + so the genuine public pair remains self-authenticating after construction. +- Canonical retention evidence now revalidates policy, reachability, and root + kind before preserving object identity. A frozen forged prototype cannot + bypass the same field invariants enforced by `RetentionEvidence` construction. +- Entity occurrence issuance now hydrates the complete published patch as an + entity capture and binds every normalized payload value, plus any supplied + subject, back to the requested Intent. A publication callback cannot + substitute an unrelated node or payload while retaining the original receipt + Intent. +- Auto-allocated entity receipts now bind the published subject to the + requested namespace and the published `NodeAdd` Dot. A publication callback + cannot substitute another subject while retaining the legitimate causal + coordinate. +- Entity conflict receipts no longer require or accept an occurrence. Only + `derived` and `plural` outcomes identify admitted entity writes. +- Entity Intent construction and direct PatchBuilder capture now share one + payload-record boundary: both reject arrays, primitives, and class instances. + Only plain or null-prototype property records are admitted. +- Entity Intent identity selection treats explicitly `undefined` optional + `subject` or `namespace` fields as absent, matching ordinary optional-field + semantics for spread-built inputs. +- Invalid Dot counters now report the enforced positive-safe-integer constraint + instead of describing the weaker integer-only rule. + +### Breaking + +- **`entity.add` widens the `Intent` discriminated union.** `IntentKind` and + `IntentDescriptor` are not exported by name, but both are structurally + reachable through `Intent['kind']` and `Intent['descriptor']`, so a consumer + that switches exhaustively over intent kinds stops compiling. Measured against + this branch's published surface: + + ```text + error TS2345: Argument of type '"entity.add"' + is not assignable to parameter of type 'never'. + ``` + + The runtime surface is purely additive, and this repository's own consumer + contract (`test/type-check`) still compiles because it does not switch + exhaustively. The type-level break is nonetheless real for any consumer that + opted into exhaustiveness checking, so this release targets **20.0.0**. + + Migration: add a `case 'entity.add':` arm, or stop treating the union as + closed. + ## [19.0.2] - 2026-07-29 ### Release notes diff --git a/bin/cli/v19/V19DomainInput.ts b/bin/cli/v19/V19DomainInput.ts index 61dc905b9..20ffe30ad 100644 --- a/bin/cli/v19/V19DomainInput.ts +++ b/bin/cli/v19/V19DomainInput.ts @@ -5,18 +5,10 @@ import type Intent from '../../../src/domain/api/Intent.ts'; import type Observer from '../../../src/domain/api/Observer.ts'; import type { ReadingValue } from '../../../src/domain/api/ReadingValue.ts'; import type { McpJsonValue } from '../commands/mcp/McpJsonValue.ts'; -import { - V19_PUBLIC_NOUNS, -} from '../capabilities/V19CapabilityContract.generated.ts'; +import { V19_PUBLIC_NOUNS } from '../capabilities/V19CapabilityContract.generated.ts'; import { usageErrorFrom } from '../infrastructure.ts'; -type JsonInput = - | null - | boolean - | number - | string - | JsonInput[] - | { [key: string]: JsonInput }; +type JsonInput = null | boolean | number | string | JsonInput[] | { [key: string]: JsonInput }; export const JSON_INPUT_SCHEMA: z.ZodType = z.lazy(() => z.union([ @@ -26,71 +18,125 @@ export const JSON_INPUT_SCHEMA: z.ZodType = z.lazy(() => z.string(), z.array(JSON_INPUT_SCHEMA), z.record(z.string(), JSON_INPUT_SCHEMA), - ]), + ]) ); const INTENT_SCHEMA = z.discriminatedUnion('kind', [ - z.object({ - kind: z.literal('node.add'), - subject: z.string().min(1), - }).strict(), - z.object({ - kind: z.literal('node.remove'), - subject: z.string().min(1), - }).strict(), - z.object({ - kind: z.literal('edge.add'), - from: z.string().min(1), - to: z.string().min(1), - label: z.string().min(1), - }).strict(), - z.object({ - kind: z.literal('edge.remove'), - from: z.string().min(1), - to: z.string().min(1), - label: z.string().min(1), - }).strict(), - z.object({ - kind: z.literal('property.set'), - subject: z.string().min(1), - key: z.string().min(1), - value: JSON_INPUT_SCHEMA, - }).strict(), + z + .object({ + kind: z.literal('node.add'), + subject: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal('node.remove'), + subject: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal('edge.add'), + from: z.string().min(1), + to: z.string().min(1), + label: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal('edge.remove'), + from: z.string().min(1), + to: z.string().min(1), + label: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal('property.set'), + subject: z.string().min(1), + key: z.string().min(1), + value: JSON_INPUT_SCHEMA, + }) + .strict(), + z + .object({ + kind: z.literal('entity.add'), + subject: z.string().min(1).optional(), + namespace: z.string().min(1).optional(), + properties: z + .record(z.string().min(1), JSON_INPUT_SCHEMA) + .refine((properties) => Object.keys(properties).length > 0, { + message: 'entity.add requires at least one property', + }), + }) + .strict(), ]); const READING_SCHEMA = z.discriminatedUnion('kind', [ - z.object({ - kind: z.literal('property.get'), - subject: z.string().min(1), - key: z.string().min(1), - }).strict(), - z.object({ - kind: z.literal('node.exists'), - subject: z.string().min(1), - }).strict(), - z.object({ - kind: z.literal('neighborhood'), - subject: z.string().min(1), - direction: z.enum(['out', 'in', 'both']).optional(), - labels: z.array(z.string().min(1)).optional(), - limit: z.number().int().positive().optional(), - cursor: z.string().min(1).optional(), - }).strict(), + z + .object({ + kind: z.literal('property.get'), + subject: z.string().min(1), + key: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal('node.exists'), + subject: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal('neighborhood'), + subject: z.string().min(1), + direction: z.enum(['out', 'in', 'both']).optional(), + labels: z.array(z.string().min(1)).optional(), + limit: z.number().int().positive().optional(), + cursor: z.string().min(1).optional(), + }) + .strict(), ]); export function intentFromText(text: string): Intent { try { return intentFromValue(JSON.parse(text)); } catch (error) { - throw usageErrorFrom( - `Invalid ${V19_PUBLIC_NOUNS.Intent} JSON`, - error, - ); + throw usageErrorFrom(`Invalid ${V19_PUBLIC_NOUNS.Intent} JSON`, error); } } export function intentFromValue(value: McpJsonValue): Intent { const descriptor = parseIntentDescriptor(value); + return descriptor.kind === 'entity.add' + ? entityIntentFrom(descriptor) + : elementIntentFrom(descriptor); +} + +function entityIntentFrom( + descriptor: Extract, { kind: 'entity.add' }> +): Intent { + if (descriptor.subject !== undefined && descriptor.namespace === undefined) { + return intent.entity.add({ + subject: descriptor.subject, + properties: descriptor.properties, + }); + } + if (descriptor.namespace !== undefined && descriptor.subject === undefined) { + return intent.entity.addAuto({ + namespace: descriptor.namespace, + properties: descriptor.properties, + }); + } + throw usageErrorFrom( + 'Invalid Intent entity.add identity', + 'exactly one of subject or namespace is required' + ); +} + +function elementIntentFrom( + descriptor: Exclude, { kind: 'entity.add' }> +): Intent { if (descriptor.kind === 'node.add') { return intent.node.add(descriptor); } @@ -106,9 +152,7 @@ export function intentFromValue(value: McpJsonValue): Intent { return intent.property.set(descriptor); } -function parseIntentDescriptor( - value: McpJsonValue, -): z.infer { +function parseIntentDescriptor(value: McpJsonValue): z.infer { try { return INTENT_SCHEMA.parse(value); } catch (error) { @@ -116,24 +160,15 @@ function parseIntentDescriptor( } } -export function observerFromText( - observerId: string, - text: string, -): Observer { +export function observerFromText(observerId: string, text: string): Observer { try { return observerFromValue(observerId, JSON.parse(text)); } catch (error) { - throw usageErrorFrom( - `Invalid ${V19_PUBLIC_NOUNS.Observer} JSON`, - error, - ); + throw usageErrorFrom(`Invalid ${V19_PUBLIC_NOUNS.Observer} JSON`, error); } } -export function observerFromValue( - observerId: string, - value: McpJsonValue, -): Observer { +export function observerFromValue(observerId: string, value: McpJsonValue): Observer { const descriptor = parseReadingDescriptor(value); if (descriptor.kind === 'property.get') { return propertyObserver(observerId, descriptor); @@ -144,68 +179,40 @@ export function observerFromValue( return neighborhoodObserver(observerId, descriptor); } -function parseReadingDescriptor( - value: McpJsonValue, -): z.infer { +function parseReadingDescriptor(value: McpJsonValue): z.infer { try { return READING_SCHEMA.parse(value); } catch (error) { - throw usageErrorFrom( - `Invalid ${V19_PUBLIC_NOUNS.Observer} ${V19_PUBLIC_NOUNS.Reading}`, - error, - ); + throw usageErrorFrom(`Invalid ${V19_PUBLIC_NOUNS.Observer} ${V19_PUBLIC_NOUNS.Reading}`, error); } } function propertyObserver( observerId: string, - descriptor: Extract< - z.infer, - { readonly kind: 'property.get' } - >, + descriptor: Extract, { readonly kind: 'property.get' }> ): Observer { - return createObserver( - observerId, - reading.property(descriptor), - identityDecoder, - ); + return createObserver(observerId, reading.property(descriptor), identityDecoder); } function nodeObserver( observerId: string, - descriptor: Extract< - z.infer, - { readonly kind: 'node.exists' } - >, + descriptor: Extract, { readonly kind: 'node.exists' }> ): Observer { - return createObserver( - observerId, - reading.node.exists(descriptor), - identityDecoder, - ); + return createObserver(observerId, reading.node.exists(descriptor), identityDecoder); } function neighborhoodObserver( observerId: string, - descriptor: Extract< - z.infer, - { readonly kind: 'neighborhood' } - >, + descriptor: Extract, { readonly kind: 'neighborhood' }> ): Observer { const options = { subject: descriptor.subject, - ...(descriptor.direction === undefined - ? {} - : { direction: descriptor.direction }), + ...(descriptor.direction === undefined ? {} : { direction: descriptor.direction }), ...(descriptor.labels === undefined ? {} : { labels: descriptor.labels }), ...(descriptor.limit === undefined ? {} : { limit: descriptor.limit }), ...(descriptor.cursor === undefined ? {} : { cursor: descriptor.cursor }), }; - return createObserver( - observerId, - reading.neighborhood(options), - identityDecoder, - ); + return createObserver(observerId, reading.neighborhood(options), identityDecoder); } function identityDecoder(value: ReadingValue): ReadingValue { diff --git a/bin/presenters/V19ReadingReceipt.ts b/bin/presenters/V19ReadingReceipt.ts index 38a02fdcc..28e6ed399 100644 --- a/bin/presenters/V19ReadingReceipt.ts +++ b/bin/presenters/V19ReadingReceipt.ts @@ -12,14 +12,9 @@ import { stableStringify } from './json.ts'; import { toMcpJson } from './V19Json.ts'; import WarpError from '../../src/domain/errors/WarpError.ts'; -export type V19Receipt = - | WriteReceipt - | ObservationReceipt - | SettlementReceipt; - -export function readingEnvelope( - reading: ObservedReading, -): McpJsonValue { +export type V19Receipt = WriteReceipt | ObservationReceipt | SettlementReceipt; + +export function readingEnvelope(reading: ObservedReading): McpJsonValue { return Object.freeze({ type: 'Reading', value: readingValueToJson(reading.value), @@ -48,14 +43,19 @@ function writeReceiptEnvelope(receipt: WriteReceipt): McpJsonValue { intent: toMcpJson(receipt.intent.descriptor), outcome: toMcpJson(receipt.outcome), reason: receipt.reason ?? null, + occurrence: + receipt.occurrence === undefined + ? null + : Object.freeze({ + id: receipt.occurrence.id, + subject: receipt.occurrence.subject, + }), evidence: evidenceEnvelope(receipt.evidence), repairHints: toMcpJson([...receipt.repairHints]), }); } -function observationReceiptEnvelope( - receipt: ObservationReceipt, -): McpJsonValue { +function observationReceiptEnvelope(receipt: ObservationReceipt): McpJsonValue { return Object.freeze({ type: 'Receipt', operation: receipt.operation, @@ -67,15 +67,12 @@ function observationReceiptEnvelope( }), status: receipt.status, reason: receipt.reason ?? null, - evidence: - receipt.evidence === undefined ? null : evidenceEnvelope(receipt.evidence), + evidence: receipt.evidence === undefined ? null : evidenceEnvelope(receipt.evidence), repairHints: toMcpJson([...receipt.repairHints]), }); } -function settlementReceiptEnvelope( - receipt: SettlementReceipt, -): McpJsonValue { +function settlementReceiptEnvelope(receipt: SettlementReceipt): McpJsonValue { return Object.freeze({ type: 'Receipt', operation: receipt.operation, @@ -95,9 +92,7 @@ export function evidenceEnvelope(evidence: Evidence): McpJsonValue { support: Object.freeze(evidence.support.map(evidenceHandleEnvelope)), }; if (evidence.retention !== undefined) { - envelope['retention'] = Object.freeze( - evidence.retention.map(retentionEvidenceEnvelope), - ); + envelope['retention'] = Object.freeze(evidence.retention.map(retentionEvidenceEnvelope)); } if (evidence.tick !== undefined) { envelope['tick'] = Object.freeze({ @@ -108,30 +103,26 @@ export function evidenceEnvelope(evidence: Evidence): McpJsonValue { return Object.freeze(envelope); } -function readingCoordinateEnvelope( - coordinate: ObservedReading['coordinate'], -): McpJsonValue { +function readingCoordinateEnvelope(coordinate: ObservedReading['coordinate']): McpJsonValue { return Object.freeze({ basis: evidenceHandleEnvelope(coordinate.basis), lane: coordinate.lane, ...(coordinate.tick === undefined ? {} - : { tick: Object.freeze({ - id: coordinate.tick.id, - lane: coordinate.tick.lane, - }) }), + : { + tick: Object.freeze({ + id: coordinate.tick.id, + lane: coordinate.tick.lane, + }), + }), }); } -function evidenceHandleEnvelope( - handle: Readonly<{ readonly id: string }>, -): McpJsonValue { +function evidenceHandleEnvelope(handle: Readonly<{ readonly id: string }>): McpJsonValue { return Object.freeze({ id: handle.id }); } -function retentionEvidenceEnvelope( - retention: RetentionEvidence, -): McpJsonValue { +function retentionEvidenceEnvelope(retention: RetentionEvidence): McpJsonValue { return Object.freeze({ witness: evidenceHandleEnvelope(retention.witness), policy: retention.policy, @@ -186,32 +177,25 @@ function readingValueToJson(value: ReadingValue): McpJsonValue { } function isReadingValueObject( - value: ReadingValue, + value: ReadingValue ): value is { readonly [key: string]: ReadingValue } { return ( - value !== null - && typeof value === 'object' - && !isReadingValueArray(value) - && !(value instanceof ImmutableBytes) + value !== null && + typeof value === 'object' && + !isReadingValueArray(value) && + !(value instanceof ImmutableBytes) ); } -function isReadingValueArray( - value: ReadingValue, -): value is readonly ReadingValue[] { +function isReadingValueArray(value: ReadingValue): value is readonly ReadingValue[] { return Array.isArray(value); } -function isJsonObject( - value: McpJsonValue, -): value is { readonly [key: string]: McpJsonValue } { +function isJsonObject(value: McpJsonValue): value is { readonly [key: string]: McpJsonValue } { return value !== null && typeof value === 'object' && !Array.isArray(value); } -function requireString( - value: McpJsonValue | undefined, - field: string, -): string { +function requireString(value: McpJsonValue | undefined, field: string): string { if (typeof value !== 'string' || value.length === 0) { throw presentationError(`${field} must be a non-empty string`); } diff --git a/docs/READINGS_AND_OPTICS.md b/docs/READINGS_AND_OPTICS.md new file mode 100644 index 000000000..c1e5a649d --- /dev/null +++ b/docs/READINGS_AND_OPTICS.md @@ -0,0 +1,510 @@ +# Readings & Optics + +_The reference pattern for modelling data in a WARP graph. Written after the +Think census. Kept short on purpose. Every rule here has a body count._ + +--- + +## 0. Read this first + +`git-warp` is a **provenance graph**. Its capabilities — `patchesFor(id)`, +`materializeSlice(id)`, checkpoints that bound replay, index construction from +patch footprints alone — are all defined over **fine-grained, addressable +facts**. Every one of them evaporates the moment you hand it a coarse mutable +container. + +The Think census (see §12) is what that evaporation looks like measured: 274 +patches touching **4 nodes**, **0 edges**, cones indistinguishable from _no +history_, one 8.57 MiB decoded object against a 5 MiB ceiling, a store that +could not be compacted and could not be repaired. The substrate did nothing +wrong. It faithfully recorded what the application wrote. What the application +wrote was **a read model, stored inside the event log, in place of facts**. + +The rules in this document exist to make that mistake unrepresentable — or at +least unmistakable — in every future WARP application. Read the rules first; +the worked example is at the bottom. + +--- + +## 1. The two-line contract + +> **1.** The log records **facts**. The state is a **fold** over them. +> **2.** Facts are **immutable**, **addressable**, and **minimal**. + +Everything else in this document is a consequence. + +--- + +## 2. What is a fact? + +A fact is a statement about the world that will be true forever regardless of +what happens next. `"James wrote 'probe write two' at 2026-08-01T05:13:00Z"` is +a fact. `"There are 65 memories on page 111"` is not — it is a snapshot of a +derived count, and the very next capture invalidates it. + +**A fact is what an outside observer, replaying your log, could reconstruct +without consulting your read model.** + +If you have to look at your own state to know what to write next, you are +writing state, not facts. + +--- + +## 3. What is an entity? + +An entity is anything a user, an operator, or a downstream computation ever +wants to ask a _targeted_ question about. If someone might ever ask +`patchesFor("entry:...")`, `slice("entry:...")`, or "when did this happen and +what changed it?" — it is an entity. + +**The test:** if `patchesFor(id)` on a good store must return a non-empty, +history-bearing cone, then `id` is an entity and it must be a node. + +The four IDs in the Think census that looked like entities but resolved to +`patchesFor(...) → []` were, in the substrate's honest opinion, _not +entities_. They were property keys of container nodes. The substrate was +telling the truth. Nobody was listening. + +--- + +## 4. Rule: entity occurrence per patch, one patch per fact + +Every captured fact is admitted by one `NodeAdd` occurrence. That patch: + +- carries the entity's non-empty initial payload as properties, +- declares an empty graph-read set, +- declares **exactly one** subject write. + +The subject and the occurrence are different identities. A subject may be a +semantic id supplied by the application, in which case several admissions can +legitimately name it. When the fact has no independent semantic key, git-warp +allocates an opaque subject from the same writer-local dot used by `NodeAdd`. +In both forms, the receipt carries the distinct substrate occurrence. + +This is the _single-subject capture_ shape. Its recorded footprint (§8) +describes exactly the operands encoded by the patch. It does not prove that +application code made no prior graph read before choosing the subject or +payload; such a dependency is absent from the patch unless the caller declares +it. An allocated subject has a singleton cone until another patch names it; a +reused semantic subject has one cone containing its several occurrences. +Neither case changes the declared patch shape. + +The corresponding lint (which the substrate should enforce, see §11): + +```text +REJECT any capture patch whose read set is non-empty, + whose write set is not exactly one subject, + or whose initial payload is empty. +``` + +Do not infer semantic-subject uniqueness from this shape. Dots identify CRDT +operations; occurrence coordinates identify admissions; neither turns the +subject into a distributed uniqueness constraint. + +--- + +## 5. Rule: containers are edges, not properties + +The single most dangerous shape in a WARP store is a node whose property is a +collection of other things: + +```text +NODE bucket:2026-08-01 + entries: [ {...}, {...}, {...}, ... ] // ← sin +``` + +Every append rewrites the collection. Every rewrite is a `PropSet` of the +whole value. Growth is quadratic in appends. The container's cone is every +patch that ever touched it. This is the four-node sin, in its purest form. + +The correction is not "smaller collections" or "more container nodes." The +correction is: + +```text +NODE bucket:2026-08-01 // created once +NODE entry: // created once, per capture +EDGE bucket:2026-08-01 → entry: // added once, per capture +``` + +Each edge is an **immutable fact whose cone is Θ(1)**. Enumeration of a +bucket's contents is a bounded scan of edges-from-bucket. The bucket is never +`PropSet`. If it were, that would be the sin returning at smaller scale. + +**Id-arrays-in-properties are the sin in miniature.** Detecting them is easy: +the census (§12) will show `edges: 0` and one hot property containing every id +ever added. + +--- + +## 6. Rule: order is a substrate fold, not an application clock + +Do not store `next` / `prev` / `previousKindId` pointers as **authoritative** +ordering. Do not invent an application tuple to replace them. git-warp already +owns the distinct ordering questions: + +- a dot is unique operation identity, not a timestamp; +- a version vector answers causal partial-order questions, and concurrent + vectors are incomparable; +- an `EventId` supplies the canonical deterministic linearization + `lamport → writerId → patchSha → opIndex` within one worldline; a reading + spanning independent worldlines orders the worldline before the `EventId`. + +Retrieval optics fold admitted occurrences in that substrate order. A field +such as `capturedAt` may remain application payload for human chronology and +time-window filtering, but it establishes neither identity, causality, +admission order, nor correctness. + +Prev-pointers as denormalized _hints_ are permissible; prev-pointers as the +source of truth for order are not, for two reasons: + +1. Writing entry N with a pointer to entry N−1 is a **read-then-write of + different nodes**. The syntactic footprint (§8) will not see the + dependency, so the substrate cannot reason about it. Any code that trusts + the pointer is trusting something the provenance graph does not know + about. +2. Order-by-link is O(chain-length) to reconstruct and O(everything) to + repair if a link goes bad. An optic over canonical occurrence coordinates + repairs itself from retained substrate evidence. + +If you must denormalize a prev-pointer for a hot query, do it — and write +down, out loud, that no consumer may treat it as authoritative. Kindly assume +your future self will forget. + +--- + +## 7. Rule: derived data lives anywhere except where facts live + +Caches, indexes, projections, materialized views, precomputed aggregates, +"the read model" — all fine. They can live in a sidecar file, in a checkpoint +manifest, in a commit trailer, in memory, in a spreadsheet on the operator's +desk. They **cannot** live inside the patch log. + +The test is simple: **if I delete this thing, can I regenerate it from the +log?** If yes, it is derived, and it is safe. If no, it is a fact — write it +into the log properly as an entity (§4) rather than smuggling it in as an +overwritten property. + +The commit trailer footprint discussion in Paper III is the same rule applied +recursively: a trailer footprint is a stored read-model too, redeemed only +because it stays _derived_, _adjacent_, _verifiable_, and _deletable_ — the +same design as git's own [changed-path Bloom +filters](https://devblogs.microsoft.com/devops/super-charging-the-git-commit-graph-iv-bloom-filters/). + +--- + +## 8. The syntactic footprint honesty constraint + +`PatchBuilder` derives each patch's `reads` / `writes` footprint from the +**operand ids literally mentioned in the patch's ops**. That footprint is a +complete description of the encoded operands. It is not necessarily a complete +description of semantic dependency: `PatchBuilder` cannot observe state that +application code read before constructing an intent or payload. + +The capture constructor guarantees one declared subject write and an empty +declared read set. It does not prove that application code made no prior graph +read. If a caller loads graph state, computes a payload, and then submits +`entity.add`, the recorded footprint under-approximates that dependency just as +it would for any other patch. Subject allocation and occurrence ordering are +substrate operations rather than hidden graph reads, but they cannot attest to +how an application chose its payload. Treat recorded footprints as a lower +bound unless semantic reads are declared or a stronger capability supplies +evidence that there were none. The API should surface this distinction as a +value, not hide it in prose: + +```text +type ConeExactness = "exact" | "under-approximate" +patchesFor(id): { patches: [...], exactness: ConeExactness } +``` + +Applications that need exact slicing must declare semantic reads explicitly or +use an API that tracks and attests their absence. Choosing the entity-capture +shape alone is not such an attestation. + +--- + +## Provenance and diagnostics + +_Deliberately unnumbered: `ProvenanceController` links here as +`docs/READINGS_AND_OPTICS.md#provenance-and-diagnostics`, and a numeric prefix +would change the slug. If you renumber this document, do not number this +heading._ + +### Enumeration is not provenance + +Two operations answer two different questions. Conflating them is the fastest +way to make the word "cone" mean nothing. + +| Question | Operation | Answers | +| -------------------------------------- | ---------------------------------------- | -------------- | +| _Which entities belong to this group?_ | `edgesFrom(bucket)`, range scan over ids | **membership** | +| _Which patches produced this fact?_ | `patchesFor(id)`, `materializeSlice(id)` | **causation** | + +An edge from a bucket to an entry is an immutable membership fact whose own +cone is Θ(1). That does **not** make the bucket's graph descendants part of the +bucket's backward cone. `patchesFor(day)` returns the patches that produced the +day node — not every memory that happens to hang off it. + +The rule: **never build an API that takes a container id and returns its +members' history under the name "slice."** Enumerate, then request provenance +per entity. If you find yourself wanting `slice(day)` to mean "traverse +descendants," you have two concepts sharing one word, and the next reader will +inherit the confusion rather than the distinction. + +### Cone exactness is a value, not a footnote + +§8 establishes that the entity-capture constructor constrains the recorded +shape but cannot establish semantic dependency completeness. An exactness +classification must come from evidence about the entire application operation, +not from the `entity.add` discriminator alone, and must travel with the answer: + +```text +type ConeExactness = "exact" | "under-approximate" + +patchesFor(id): { patches: [...], exactness: ConeExactness } +``` + +An `under-approximate` cone is still useful — it is a lower bound on truth, and +lower bounds are fine as long as nobody mistakes them for the truth. Without +evidence establishing semantic completeness, `under-approximate` is the honest +classification. A cone returned without its exactness label is an unlabelled +lower bound, which is how a diagnostic becomes a false guarantee. + +Extend the same honesty to any reading built on top: + +```text +ReadingEvidence { result, basis, aperture, derivation, exactness } +``` + +### When provenance reading is unavailable + +Provenance requires a live index. The index is built from patch footprints +(§8), and materializations that resume from a state cache or a checkpoint +carrying no index cannot rebuild it for the patches they skipped. Those report +**degraded** rather than presenting an empty index as complete evidence. + +| Condition | Code | Meaning | +| -------------------------------- | ----------------------- | --------------------------------------------------------------- | +| No reading basis open | `E_NO_STATE` | Open a worldline or a checkpoint-backed reading first. | +| Index does not cover the history | `E_PROVENANCE_DEGRADED` | The answer would be silently incomplete, so no answer is given. | + +Refusing is correct. The alternative — returning `[]` — is +indistinguishable from _"this entity has no history,"_ and that ambiguity is +exactly what let the Think census (§12) go unnoticed: four container ids +resolved to empty cones, and nothing anywhere said _"empty because absent"_ +rather than _"empty because never recorded."_ + +**Corollary for callers:** treat an empty cone as a question, not an answer. +Ask whether the id is an entity at all (§3) before concluding it has no past. + +--- + +## 9. Indexes are derived optics over entities + +Once every entity is a node (§4), you can build any index you want as a +**pure function over the entity set**. The index does not need to be +stored — it can be materialized on demand, cached, thrown away, rebuilt. + +Common shapes: + +- **Occurrence-order scan.** Fold entity occurrences by git-warp's canonical + event ordering and stop when the requested bound is satisfied. The subject + remains identity, not an application-owned clock disguised as an id. +- **Temporal trie.** Group entries by `YYYY/MM/DD/HH` for human-time filtering. + Derive it from optional application metadata such as `capturedAt`, never from + causal identity. If materialized for + performance, use **immutable bucket nodes plus `EdgeAdd` membership** — + never `PropSet` a bucket. See §5. +- **Kind scan.** Enumerate all entities of a given kind. This is O(entities-of-that-kind) + and it is only viable if you have budgeted for that scan. If a kind grows + without bound, you need a real index; a kind scan is a poor-man's secondary + index and it becomes the O(N) read reborn under a different name. +- **Content-addressed body.** For entities whose payload is a large blob + (attachments, transcripts, embeddings), store the body as a content-addressed + blob and keep only `{hash, capturedAt?, meta}` on the node. Identical bodies + deduplicate for free. This also keeps entity patches small enough to stay + well under any per-object decode ceiling regardless of future feature drift. + +**Granularity is empirical.** Pick a bucket size that fits your write rate; +document the splitting rule; do not carve the granularity into the storage +contract. Today's hour-bucket is tomorrow's hot page. + +--- + +## 10. Checkpoints, and the difference between a bounded tail and a bounded cone + +A **checkpoint** bounds the _replay tail_: given a stable checkpoint at +coordinate C, materialization needs to replay only patches after C. Set a +default policy (`{ every: 64 }` is a reasonable start) and — critically — +**make sure the trigger fires on the actual read path your application uses**. +The Think outage in the census (§12) was not a missing policy; it was a +policy whose trigger (`_onMaterialized`) was never called by the +lane/bounded-reader path Think actually took. A checkpoint that never fires +is not a safety mechanism; it is decoration on the one-way door. + +A checkpoint is not the same thing as a bounded cone. If your application has +a mutable global head/index node whose backward cone grows linearly with +history, then even with a fresh checkpoint every query still slices something +whose cone-in-principle is Θ(N). The tail is bounded; the geometry is not. +Design for **bounded cones by construction** (per-entity nodes, immutable +buckets, edges not property arrays); use checkpoints to bound replay of the +things that are legitimately global (aggregate summaries, cross-entity roll-ups). + +Two shapes are asymptotically dangerous even in an otherwise clean model: + +- A `total`/`headPage`/`current` node updated on every append. Its cone is + the history of the store. Replace with either an immutable append record + or an out-of-log ref. +- A `provenanceIndex.cbor` (or any serialized index) that lives as **one + growing object**. It reintroduces the decode-ceiling cliff by another name. + Chunk it, or store it as an actual graph of immutable segments (Datomic's + shape). + +--- + +## 11. Write-path affordances the substrate should provide + +Documentation prevents the first sin. Affordances prevent the ten-thousandth. +The substrate should — over time — make the following properties directly +enforceable at write time: + +- **Capture-shape lint.** Reject patches that claim to create an entity but + read from other nodes or write to more than one id. (See §4.) +- **Substrate allocation and receipts.** Allocate subjects for facts without an + independent semantic key from writer-local causal machinery, and return an + opaque occurrence coordinate whose causal relation and deterministic order + remain owned by git-warp. +- **Amplification lint.** Warn when a single patch's byte size exceeds a + factor of its payload — e.g. a 15-byte capture producing a 33 KiB patch. +- **Hot-node lint.** Warn when a single node absorbs a disproportionate + share of writes over a rolling window. In the Think census one node had + 49.6% of all writes; that is a signal, not noise. +- **Decode-boundary invariant.** Track the largest independently decoded + object; alarm when it approaches the ceiling _before_ it hits. +- **`warp census`.** Ship the forensic harness (§12) as a first-class + diagnostic command any application can run against its own store, with + the properties in §12 reified as machine-checkable checks. An app that + passes the census conforms to this document. An app that fails is + reading the same paragraph that saved (or did not save) Think. + +None of this replaces the rules in §§1–10. All of it makes the rules +noisy to violate. + +--- + +## 12. Exhibit A: the Think census (preserved as a relic) + +Full census of a real Think store, taken at the point Think became +unrecoverable. All numbers are what the store actually contained. + +```text +patches : 274 +distinct nodes written : 4 +op types : 272 PropSet, 2 NodeAdd +edges : 0 +total patch bytes : 6.48 MiB (~24 KiB avg patch) + +writes per node: + 136x read_model:v19:index:capture + 65x read_model:v19:index:capture:page:00000111 + 50x read_model:v19:index:capture:page:00000110 + 23x read_model:v19:index:capture:page:00000112 + +single-capture pathology: + input : "probe write two" (15 bytes) + emitted patch : 33,624 bytes + amplification : ~2,200× + +per-page growth (page 00000112, 23 appends): + 335 → 2,124 → ... → 33,624 bytes per append + total : 409.3 KiB + shape : Θ(appends²) because each append re-serialised + the whole page + +recovery: + materialized state : 16.4 MiB + largest object : 8,568,034 bytes decoded + hard ceiling : 5,242,880 bytes (MAX_CBOR_DECODE_BYTES) + repair : E_INTERNAL: CBOR decode rejected + +read cost, same code, same optic: + fresh store (14 commits, replay 10 ) : 79 spawns, 1.7s + light store (72 commits, replay 10 ) : 232 spawns, 1.9s + heavy store (502 commits, replay 262) : 5,267 spawns, 25.7s + + --limit=1 costs 5,267 spawns. + --limit=50 costs 5,267 spawns. + Cost tracked history size, not query size. +``` + +The six failed promises the census made testable (and the properties every +application's own census should check): + +1. **Cone ≪ universe.** The cone of any addressable id is a strict subset + of the total patch set. _In Think: false. Cone of a page node was every + patch that touched it. `patchesFor("entry:...")` returned `[]`._ +2. **Cost ∝ query.** Read cost scales with what was asked for, not with + what has ever been written. _In Think: false. `--limit=1 ≡ --limit=50`._ +3. **Tail bounded.** The replay tail past the latest checkpoint is bounded + by policy. _In Think: false. Checkpoint frozen for two days, 262 + unreplayed patches, +2 per write._ +4. **Compaction feasible.** State can always be re-materialized. _In Think: + false. One object exceeded the decode ceiling._ +5. **Ops are facts.** Patches record what happened, not what the read model + is. _In Think: false. 272 of 274 ops were `PropSet` overwrites of a + read-model cache._ +6. **Writes ∝ payload.** Patch bytes scale with the fact being recorded, not + with the size of the accumulated container. _In Think: false. 15 bytes + in, 33,624 bytes out._ + +Each of these is machine-checkable. Each of these is what `warp census` +should ship as a named test. + +--- + +## 13. The short version, for posting above the desk + +- **Entities are nodes.** One subject, one addressable entity; repeated + admissions remain distinct occurrences. +- **Facts, not state.** Each capture is one `NodeAdd`. It declares no graph + reads, declares one subject write, and returns one substrate occurrence. +- **Containers are edges.** Never put a growing collection in a property. + `EdgeAdd(container → member)` per member. Never `PropSet` the container. +- **Order is a substrate fold.** Version vectors answer causality; canonical + event order answers deterministic listing; application time stays metadata. +- **Derived lives outside.** Caches, indexes, projections — anywhere but the + log. If you cannot regenerate it from the log, it is a fact; make it one. +- **Cones by construction.** Build bounded cones into the shape. Do not rely + on checkpoints to hide unbounded ones. +- **Ceilings are covenants.** When a hard limit fires, it is telling the + truth. Fix the shape; do not raise the ceiling. + +--- + +## 14. Related reading + +- **Paper III — Computational Holography & Provenance Payloads.** + The design this document is the applied corollary of. +- **Greg Young — CQRS Documents.** + The event/projection distinction, stated at length, fifteen years earlier. + +- **Datomic — Architecture.** + The reference existence proof for "log justifies snapshot; snapshot serves + reads; nobody replays anything except to rebuild trust." + +- **Jepsen — Datomic Pro 1.0.7075.** + Independent read on the same shape. + +- **Git — commit-graph and changed-path Bloom filters.** + Derived-data-as-sidecar done well. + +- **Whittaker et al. — Wat-Provenance.** + Why syntactic cones over-approximate true causal explanation, and why + application-level provenance is a distinct problem from storage-operand + provenance. + + +--- + +_This document exists because it did not exist when Think was written. Keep +it up to date. If a future application's census fails a property in §12, the +correction goes here first, and only then into code._ diff --git a/docs/topics/cli.md b/docs/topics/cli.md index 13b16e204..f4863da64 100644 --- a/docs/topics/cli.md +++ b/docs/topics/cli.md @@ -39,7 +39,55 @@ git warp write \ ``` `write` returns a canonical Receipt. Supported public Intent kinds are -`node.add`, `node.remove`, `edge.add`, `edge.remove`, and `property.set`. +`node.add`, `node.remove`, `edge.add`, `edge.remove`, `property.set`, and +`entity.add`. + +`entity.add` creates one entity and its initial payload in a single patch: + +```bash +git warp write \ + --lane users \ + --writer local \ + --json \ + --intent '{"kind":"entity.add","subject":"user:alice","properties":{"role":"admin"}}' +``` + +That patch declares an empty read set and exactly one subject write. This +describes the operands encoded by the patch, not every dependency in the +calling application. If the caller read graph state before constructing the +JSON payload, that dependency remains undeclared. The intent requires at least +one property. + +It does **not** check that the subject is new. `git warp write` goes through a +lane, and a lane writer never materializes, so the uniqueness guard has no basis +in which to observe an existing id and never fires. Writing the same subject +twice is admitted both times, whether from one lane or from two writers, and the +join merges the results into one entity with a two-patch cone. The guard exists +for a directly constructed `PatchBuilder` opened against a materialized state. + +Use the supplied-subject form only when the application already owns a semantic +identity. Repeated admissions of that subject remain distinct occurrences even +though their provenance cones share one address. + +When the fact has no independent semantic key, ask git-warp to allocate the +subject from the same writer-local dot that creates it: + +```bash +git warp write \ + --lane users \ + --writer local \ + --json \ + --intent '{"kind":"entity.add","namespace":"entry","properties":{"role":"admin"}}' +``` + +The CLI JSON envelope exposes only `occurrence.subject` and the opaque +`occurrence.id`; JSON has no comparison methods. +The in-process TypeScript `EntityOccurrence` additionally provides `relationTo` +for causal partial-order questions within a worldline; occurrences from +independent worldlines are concurrent. Its `compare` method orders the worldline +first, then uses git-warp's canonical `EventId` linearization for a deterministic +list. Do not parse the allocated subject or occurrence id. Do not use a payload +timestamp for uniqueness or causal order. ## Prepare and observe a Lane diff --git a/docs/topics/reference.md b/docs/topics/reference.md index 22b4f90c8..e73415097 100644 --- a/docs/topics/reference.md +++ b/docs/topics/reference.md @@ -36,11 +36,13 @@ Runtime @ index.ts#L13 ### Type exports -Source: `index.ts`. Count: 30. +Source: `index.ts`. Count: 32. ```text -AdmissionOutcome @ index.ts#L46 +AdmissionOutcome @ index.ts#L50 CoordinateReference @ index.ts#L24 +EntityCausalRelation @ index.ts#L48 +EntityOccurrence @ index.ts#L47 Evidence @ index.ts#L20 EvidenceHandle @ index.ts#L20 Intent @ index.ts#L21 @@ -56,8 +58,8 @@ ObserverCardinality @ index.ts#L33 Reading @ index.ts#L34 ReadingCoordinate @ index.ts#L36 ReadingValue @ index.ts#L37 -Receipt @ index.ts#L47 -RepairHint @ index.ts#L48 +Receipt @ index.ts#L51 +RepairHint @ index.ts#L52 RuntimeForkOptions @ index.ts#L15 RuntimeOpenOptions @ index.ts#L16 RuntimeSettlementOptions @ index.ts#L17 diff --git a/index.ts b/index.ts index 0d325ce7c..555147f96 100644 --- a/index.ts +++ b/index.ts @@ -43,6 +43,10 @@ export type { default as SettlementPreview } from './src/domain/api/SettlementPr export type { default as SettlementReceipt } from './src/domain/api/SettlementReceipt.ts'; export type { default as SettlementPlan } from './src/domain/settlement/SettlementPlan.ts'; export type { default as WriteReceipt } from './src/domain/api/WriteReceipt.ts'; +export type { + default as EntityOccurrence, + EntityCausalRelation, +} from './src/domain/api/EntityOccurrence.ts'; export type { AdmissionOutcome } from './src/domain/api/AdmissionOutcome.ts'; export type { Receipt } from './src/domain/api/PublicReceipt.ts'; export type { RepairHint } from './src/domain/api/ReceiptSupport.ts'; diff --git a/package.json b/package.json index 7ca9291ce..af6fcb3ca 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "check:capabilities": "node scripts/GenerateV19CapabilityContract.ts --check", "generate:sdk-fixture": "node scripts/generated-sdk/GenerateUsersSdkFixture.ts", "check:sdk-fixture": "node scripts/generated-sdk/GenerateUsersSdkFixture.ts --check", - "lint": "sh -c 'eslint . \"$@\" && npm run lint:test-law && npm run lint:source-size && npm run lint:cas-invariants' --", + "lint": "sh -c 'eslint . \"$@\" && npm run lint:test-law && npm run lint:source-size && npm run lint:cas-invariants && npm run lint:machine-paths' --", "lint:ratchet": "sh scripts/lint-ratchet.sh", "lint:md": "markdownlint \"**/*.md\" --ignore node_modules --ignore \"**/node_modules/**\" && npm run lint:mermaid", "lint:mermaid": "node scripts/validate-mermaid.ts", @@ -88,6 +88,7 @@ "lint:source-size": "node scripts/source-size-gate.ts", "lint:source-backed-reference": "node scripts/check-source-backed-reference.ts", "lint:source-version-names": "node scripts/source-version-name-policy.ts", + "lint:machine-paths": "node scripts/check-machine-local-paths.ts", "lint:contamination": "node scripts/contamination-map.ts", "lint:quarantine-graduate": "node scripts/quarantine-graduate-check.ts", "format": "prettier --write .", diff --git a/scripts/GitMachineLocalPathGuard.ts b/scripts/GitMachineLocalPathGuard.ts new file mode 100644 index 000000000..f002abf61 --- /dev/null +++ b/scripts/GitMachineLocalPathGuard.ts @@ -0,0 +1,232 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs'; +import { join } from 'node:path'; + +import { MachineLocalPathPolicy } from './MachineLocalPathPolicy.ts'; + +const MAX_INSPECTED_BLOB_BYTES = 512 * 1024 * 1024; +const OBJECT_ID_PATTERN = /^[0-9a-f]{40,64}$/u; +const ZERO_OBJECT_PATTERN = /^0+$/u; + +export class GitMachineLocalPathGuard { + readonly #repository: string; + readonly #policy: MachineLocalPathPolicy; + + constructor(repository: string, policy: MachineLocalPathPolicy) { + this.#repository = repository; + this.#policy = policy; + } + + findWorkingTreePaths(): string[] { + const inventory = execFileSync( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], + { cwd: this.#repository, encoding: 'utf8' } + ); + const paths = inventory.split('\0').filter((path) => path.length > 0); + + return paths.filter((path) => { + const absolutePath = join(this.#repository, path); + if (!existsSync(absolutePath)) { + return false; + } + const metadata = lstatSync(absolutePath); + if (metadata.isSymbolicLink()) { + return this.#policy.containsMachineLocalPath(readlinkSync(absolutePath, 'utf8')); + } + return this.#containsMachineLocalPath(readFileSync(absolutePath)); + }); + } + + findStagedPaths(): string[] { + const inventory = execFileSync( + 'git', + ['diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z'], + { + cwd: this.#repository, + encoding: 'utf8', + } + ); + const paths = inventory.split('\0').filter((path) => path.length > 0); + + return paths.filter((path) => { + const bytes = execFileSync('git', ['cat-file', 'blob', `:${path}`], { + cwd: this.#repository, + maxBuffer: MAX_INSPECTED_BLOB_BYTES, + }); + return this.#containsMachineLocalPath(bytes); + }); + } + + findOutgoingObjects(pushUpdates: string, remoteName: string): string[] { + const outgoingObjectIds = new Set(); + const remoteTips = this.#findRemoteTips(remoteName); + + for (const line of pushUpdates.split('\n')) { + if (line.trim().length === 0) { + continue; + } + const fields = line.trim().split(/\s+/u); + const localObject = fields[1]; + const remoteObject = fields[3]; + if (fields.length !== 4 || localObject === undefined || remoteObject === undefined) { + throw new Error('Malformed pre-push update'); + } + if (!OBJECT_ID_PATTERN.test(localObject) || !OBJECT_ID_PATTERN.test(remoteObject)) { + throw new Error('Malformed pre-push object id'); + } + if (ZERO_OBJECT_PATTERN.test(localObject)) { + continue; + } + + const exclusions = ZERO_OBJECT_PATTERN.test(remoteObject) ? remoteTips : [remoteObject]; + const revisionArguments = [localObject, ...exclusions.map((objectId) => `^${objectId}`)]; + const inventory = execFileSync( + 'git', + ['rev-list', '--objects', '--no-object-names', ...revisionArguments], + { cwd: this.#repository, encoding: 'utf8' } + ); + for (const objectId of inventory.split('\n')) { + if (objectId.length === 0) { + continue; + } + if (!OBJECT_ID_PATTERN.test(objectId)) { + throw new Error('Git returned a malformed outgoing object id'); + } + outgoingObjectIds.add(objectId); + } + } + + const offenders: string[] = []; + for (const objectId of outgoingObjectIds) { + const objectType = execFileSync('git', ['cat-file', '-t', objectId], { + cwd: this.#repository, + encoding: 'utf8', + }).trim(); + if (objectType === 'tree') { + continue; + } + if (objectType !== 'blob' && objectType !== 'commit' && objectType !== 'tag') { + throw new Error(`Unsupported outgoing Git object type: ${objectType}`); + } + const bytes = execFileSync('git', ['cat-file', objectType, objectId], { + cwd: this.#repository, + maxBuffer: MAX_INSPECTED_BLOB_BYTES, + }); + if (this.#containsMachineLocalPath(bytes)) { + offenders.push(`${objectType}:${objectId}`); + } + } + + return offenders.sort(); + } + + findTreePaths(revision: string): string[] { + if (!OBJECT_ID_PATTERN.test(revision)) { + throw new Error('Committed-tree scan requires an exact object id'); + } + const inventory = execFileSync('git', ['ls-tree', '-r', '-z', '--full-tree', revision], { + cwd: this.#repository, + encoding: 'utf8', + maxBuffer: MAX_INSPECTED_BLOB_BYTES, + }); + const pathsByObjectId = new Map(); + + for (const record of inventory.split('\0')) { + if (record.length === 0) { + continue; + } + const tab = record.indexOf('\t'); + if (tab < 0) { + throw new Error('Git returned a malformed tree record'); + } + const metadata = record.slice(0, tab).split(' '); + const objectType = metadata[1]; + const objectId = metadata[2]; + if (objectType !== 'blob' || objectId === undefined || !OBJECT_ID_PATTERN.test(objectId)) { + throw new Error('Git returned a malformed tree blob record'); + } + const path = record.slice(tab + 1); + const paths = pathsByObjectId.get(objectId) ?? []; + paths.push(path); + pathsByObjectId.set(objectId, paths); + } + + const leakingObjectIds = this.#findLeakingBlobIds([...pathsByObjectId.keys()]); + return [...leakingObjectIds] + .flatMap((objectId) => pathsByObjectId.get(objectId) ?? []) + .sort(); + } + + #findLeakingBlobIds(objectIds: readonly string[]): Set { + if (objectIds.length === 0) { + return new Set(); + } + const batch = execFileSync('git', ['cat-file', '--batch'], { + cwd: this.#repository, + input: objectIds.join('\n') + '\n', + maxBuffer: MAX_INSPECTED_BLOB_BYTES, + }); + const offenders = new Set(); + let offset = 0; + + for (const expectedObjectId of objectIds) { + const headerEnd = batch.indexOf(10, offset); + if (headerEnd < 0) { + throw new Error('Git returned a truncated batch header'); + } + const header = batch.subarray(offset, headerEnd).toString('utf8').split(' '); + const actualObjectId = header[0]; + const objectType = header[1]; + const sizeText = header[2]; + const size = Number(sizeText); + if ( + actualObjectId !== expectedObjectId || + objectType !== 'blob' || + sizeText === undefined || + !Number.isSafeInteger(size) || + size < 0 + ) { + throw new Error('Git returned a malformed batch blob header'); + } + const contentStart = headerEnd + 1; + const contentEnd = contentStart + size; + if (contentEnd >= batch.length || batch[contentEnd] !== 10) { + throw new Error('Git returned a truncated batch blob'); + } + if (this.#containsMachineLocalPath(batch.subarray(contentStart, contentEnd))) { + offenders.add(expectedObjectId); + } + offset = contentEnd + 1; + } + + if (offset !== batch.length) { + throw new Error('Git returned trailing batch blob data'); + } + return offenders; + } + + #findRemoteTips(remoteName: string): string[] { + if (remoteName.length === 0) { + return []; + } + const tips = execFileSync( + 'git', + ['for-each-ref', '--format=%(objectname)', `refs/remotes/${remoteName}/`], + { cwd: this.#repository, encoding: 'utf8' } + ); + return tips + .split('\n') + .filter((objectId) => objectId.length > 0) + .map((objectId) => { + if (!OBJECT_ID_PATTERN.test(objectId)) { + throw new Error('Git returned a malformed remote object id'); + } + return objectId; + }); + } + + #containsMachineLocalPath(bytes: Buffer): boolean { + return this.#policy.containsMachineLocalPath(bytes.toString('utf8')); + } +} diff --git a/scripts/MachineLocalPathPolicy.ts b/scripts/MachineLocalPathPolicy.ts new file mode 100644 index 000000000..1c9e3b6b3 --- /dev/null +++ b/scripts/MachineLocalPathPolicy.ts @@ -0,0 +1,23 @@ +const POSIX_HOME_PATTERN = [ + ['', 'Users', String.raw`[^/\s]+`].join('/') + String.raw`(?:/|$)`, + ['', 'home', String.raw`[^/\s]+`].join('/') + String.raw`(?:/|$)`, +]; +const DARWIN_TEMP_PATTERN = [ + ['', 'private', 'var', 'folders', String.raw`[^/\s]+`].join('/') + String.raw`(?:/|$)`, + ['', 'var', 'folders', String.raw`[^/\s]+`].join('/') + String.raw`(?:/|$)`, +]; +const WINDOWS_HOME_PATTERN = String.raw`[A-Za-z]:\\` + 'Users' + String.raw`\\[^\\\s]+(?:\\|$)`; + +const MACHINE_LOCAL_PATH_PATTERN = new RegExp( + [...POSIX_HOME_PATTERN, ...DARWIN_TEMP_PATTERN].join('|'), + 'u' +); +const WINDOWS_MACHINE_LOCAL_PATH_PATTERN = new RegExp(WINDOWS_HOME_PATTERN, 'iu'); + +export class MachineLocalPathPolicy { + containsMachineLocalPath(content: string): boolean { + return ( + MACHINE_LOCAL_PATH_PATTERN.test(content) || WINDOWS_MACHINE_LOCAL_PATH_PATTERN.test(content) + ); + } +} diff --git a/scripts/check-machine-local-paths.ts b/scripts/check-machine-local-paths.ts new file mode 100644 index 000000000..b4724ed5f --- /dev/null +++ b/scripts/check-machine-local-paths.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { GitMachineLocalPathGuard } from './GitMachineLocalPathGuard.ts'; +import { MachineLocalPathPolicy } from './MachineLocalPathPolicy.ts'; + +const ROOT = fileURLToPath(new URL('../', import.meta.url)); +const policy = new MachineLocalPathPolicy(); +const guard = new GitMachineLocalPathGuard(ROOT, policy); +const mode = process.argv[2] ?? '--working-tree'; +let offenders: string[]; + +if (mode === '--working-tree') { + offenders = guard.findWorkingTreePaths(); +} else if (mode === '--staged') { + offenders = guard.findStagedPaths(); +} else if (mode === '--pre-push') { + offenders = guard.findOutgoingObjects(readFileSync(0, 'utf8'), process.argv[3] ?? ''); +} else if (mode === '--tree') { + offenders = guard.findTreePaths(process.argv[3] ?? ''); +} else { + process.stderr.write(`Unknown machine-local path scan mode: ${mode}\n`); + process.exit(2); +} + +if (offenders.length > 0) { + process.stderr.write( + `Machine-local absolute paths are forbidden in ${mode} content:\n` + + offenders.map((path) => `- ${path}`).join('\n') + + '\n' + ); + process.exitCode = 1; +} diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit index 3694245ba..b06954326 100755 --- a/scripts/hooks/pre-commit +++ b/scripts/hooks/pre-commit @@ -12,6 +12,11 @@ if [ -z "$ROOT" ]; then fi cd "$ROOT" +# ── Gate 0: Machine-local path policy on exact staged blobs ──────────────── + +echo "[IRONCLAD] Scanning exact staged blobs for machine-local paths..." +node scripts/check-machine-local-paths.ts --staged + # ── Gate 1: ESLint on staged JS files ────────────────────────────────────── # Get staged JS files using NUL-delimited output for safe filename handling diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index 0fe602e97..0788f607f 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -7,13 +7,6 @@ # ═══════════════════════════════════════════════════════════════════════════ set -e -ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" -if [ -z "$ROOT" ]; then - echo "pre-push: unable to locate repo root" >&2 - exit 1 -fi -cd "$ROOT" - clear_git_repository_environment() { git_local_env_vars="$(git rev-parse --local-env-vars 2>/dev/null)" for git_local_env_var in $git_local_env_vars; do @@ -26,6 +19,21 @@ clear_git_repository_environment() { clear_git_repository_environment echo "pre-push: cleared inherited Git repository environment" +ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" +if [ -z "$ROOT" ]; then + echo "pre-push: unable to locate repo root" >&2 + exit 1 +fi +cd "$ROOT" + +REMOTE_NAME="${1:-}" +PUSH_INPUT=$(mktemp) +trap 'rm -f "$PUSH_INPUT"' EXIT +cat > "$PUSH_INPUT" + +echo "[Gate P] Scanning exact outgoing Git objects for machine-local paths..." +node scripts/check-machine-local-paths.ts --pre-push "$REMOTE_NAME" < "$PUSH_INPUT" + command_exists() { launcher="$1" cmd="$2" diff --git a/src/domain/api/DraftTimelineRuntime.ts b/src/domain/api/DraftTimelineRuntime.ts index d4665b20e..361bdfc3e 100644 --- a/src/domain/api/DraftTimelineRuntime.ts +++ b/src/domain/api/DraftTimelineRuntime.ts @@ -107,9 +107,7 @@ export async function createDraftTimeline( return await openDraftTimeline(fields); } -export async function openDraftTimeline( - fields: CreateDraftTimelineFields -): Promise { +export async function openDraftTimeline(fields: CreateDraftTimelineFields): Promise { const { runtime, context, timelineName, draftName } = fields; const persisted = await runtime.loadDraftPatchEntries(draftName); const state = createDraftState({ @@ -134,21 +132,16 @@ export async function openDraftTimeline( return draft; } -export async function createDraftReadingTarget( - draft: DraftTimeline, -): Promise { +export async function createDraftReadingTarget(draft: DraftTimeline): Promise { const state = requireDraftStateForReading(draft); const coordinate = state.forkedAt; if (coordinate === null) { throw new WarpError( 'DraftTimeline was not forked from a captured Runtime coordinate', - 'E_DRAFT_TIMELINE_BOUNDED_BASIS_UNAVAILABLE', + 'E_DRAFT_TIMELINE_BOUNDED_BASIS_UNAVAILABLE' ); } - const basis = await state.runtime.prepareStrandOptic( - draft.name, - coordinate.checkpointSha, - ); + const basis = await state.runtime.prepareStrandOptic(draft.name, coordinate.checkpointSha); const tick = await createDraftReadingTick(draft, state, basis); return Object.freeze({ tick, @@ -165,12 +158,9 @@ export async function createDraftReadingTarget( async function createDraftReadingTick( draft: DraftTimeline, state: DraftTimelineState, - basis: WarpStrandOpticBasis, + basis: WarpStrandOpticBasis ): Promise { - const frontier = basis.frontierEntries.flatMap(({ writerId, patchSha }) => [ - writerId, - patchSha, - ]); + const frontier = basis.frontierEntries.flatMap(({ writerId, patchSha }) => [writerId, patchSha]); return new Tick({ timeline: draft.name, id: await state.context.createOpaqueId('tick', [ @@ -311,39 +301,37 @@ function createDraftState(fields: DraftStateFields): DraftTimelineState { }; } -export function requireDraftStateForReading( - draft: DraftTimeline, -): DraftTimelineState { +export function requireDraftStateForReading(draft: DraftTimeline): DraftTimelineState { const state = draftStates.get(draft); if (state === undefined) { throw new WarpError( 'DraftTimeline does not belong to this runtime', - 'E_DRAFT_TIMELINE_RUNTIME_MISMATCH', + 'E_DRAFT_TIMELINE_RUNTIME_MISMATCH' ); } return state; } async function writeDraftIntent(fields: DraftWriteFields): Promise { - let draftPatchSha: string | undefined; + let draftPatch: WarpDraftPatchEntry | undefined; const receipt = await executeIntentWrite({ runtime: fields.runtime, context: fields.state.context, intent: fields.intent, commit: async (build) => { const publication = await fields.runtime.patchDraftWithEvidence(fields.draftName, build); - draftPatchSha = publication.sha; + draftPatch = publication; return publication; }, }); if (receipt.outcome.kind === 'conflict' || receipt.outcome.kind === 'obstruction') { return receipt; } - if (draftPatchSha === undefined) { + if (draftPatch === undefined) { throw new WarpError('Admitted draft write is missing its patch SHA', 'E_DRAFT_WRITE_RECEIPT'); } - fields.state.draftPatchShas.push(draftPatchSha); - fields.state.intents.push(fields.intent); + fields.state.draftPatchShas.push(draftPatch.sha); + fields.state.intents.push(intentFromPatch(draftPatch.patch)); return receipt; } diff --git a/src/domain/api/EntityOccurrence.ts b/src/domain/api/EntityOccurrence.ts new file mode 100644 index 000000000..9793860a3 --- /dev/null +++ b/src/domain/api/EntityOccurrence.ts @@ -0,0 +1,223 @@ +import VersionVector from '../crdt/VersionVector.ts'; +import WarpError from '../errors/WarpError.ts'; +import { hexEncode, textEncode } from '../utils/bytes.ts'; +import { canonicalStringify } from '../utils/canonicalStringify.ts'; +import { requireNonEmptyString } from '../utils/scalarValidation.ts'; +import type Evidence from './Evidence.ts'; +import Intent from './Intent.ts'; + +export type EntityCausalRelation = 'same' | 'before' | 'after' | 'concurrent'; + +type EntityOccurrenceReceiptBinding = { + readonly evidence: Evidence; + readonly intent: Intent; + readonly lane: string; + readonly writer: string; +}; + +type EntityOccurrenceFields = { + readonly context: Readonly>; + readonly dot: readonly [string, number]; + readonly evidence: Evidence; + readonly eventOrder: readonly [number, string, string, number]; + readonly intent: Intent; + readonly receiptWriter: string; + readonly subject: string; + readonly worldline: string; +}; + +/** + * One admitted entity creation and its opaque substrate coordinate. + * + * `id` is occurrence identity, `relationTo` answers causal questions within a + * worldline, and `compare` supplies git-warp's deterministic worldline/event + * linearization. Independent worldlines are concurrent. None of those meanings + * come from the entity subject or application timestamps. + */ +export default class EntityOccurrence { + readonly #context: VersionVector; + readonly #dot: readonly [string, number]; + readonly #eventOrder: readonly [number, string, string, number]; + readonly #evidence: Evidence; + readonly #intent: Intent; + readonly #receiptWriter: string; + readonly #worldline: string; + readonly id: string; + readonly subject: string; + + private constructor(fields: EntityOccurrenceFields) { + requireCoordinateFields(fields); + this.#context = VersionVector.from(fields.context); + this.#dot = freezeDot(fields.dot); + this.#eventOrder = freezeEventOrder(fields.eventOrder); + this.#evidence = fields.evidence; + this.#intent = fields.intent; + this.#receiptWriter = fields.receiptWriter; + this.#worldline = fields.worldline; + this.id = entityOccurrenceId(fields.worldline, fields.eventOrder); + this.subject = fields.subject; + Object.freeze(this); + } + + /** Issues an occurrence from a substrate-owned causal coordinate. */ + static issue(fields: EntityOccurrenceFields): EntityOccurrence { + return new EntityOccurrence(fields); + } + + /** Requires an occurrence to carry the exact binding used by its receipt. */ + static requireReceiptBinding( + occurrence: EntityOccurrence, + receipt: EntityOccurrenceReceiptBinding + ): EntityOccurrence { + const issued = EntityOccurrence.#requireIssued(occurrence); + requireReceiptBinding(issued.#evidence === receipt.evidence); + requireReceiptBinding(issued.#intent === receipt.intent); + requireReceiptBinding(issued.#worldline === receipt.lane); + requireReceiptBinding(issued.#receiptWriter === receipt.writer); + return issued; + } + + /** Canonical deterministic order; this does not claim causality. */ + compare(other: EntityOccurrence): number { + const right = EntityOccurrence.#requireIssued(other); + if (this.#worldline !== right.#worldline) { + return this.#worldline < right.#worldline ? -1 : 1; + } + return compareEventOrder(this.#eventOrder, right.#eventOrder); + } + + /** Causal partial-order relation backed by substrate vector context. */ + relationTo(other: EntityOccurrence): EntityCausalRelation { + const right = EntityOccurrence.#requireIssued(other); + if (this.#worldline !== right.#worldline) { + return 'concurrent'; + } + if (dotsEqual(this.#dot, right.#dot)) { + return 'same'; + } + return distinctRelation( + containsDot(this.#context, right.#dot), + containsDot(right.#context, this.#dot) + ); + } + + static #requireIssued(value: EntityOccurrence): EntityOccurrence { + if (!(value instanceof EntityOccurrence)) { + throw new WarpError( + 'Entity occurrence comparison requires an EntityOccurrence', + 'E_ENTITY_OCCURRENCE_TYPE' + ); + } + if (!(#context in value)) { + throw new WarpError( + 'EntityOccurrence was not issued by the substrate', + 'E_ENTITY_OCCURRENCE_UNAVAILABLE' + ); + } + return value; + } +} + +function requireCoordinateFields(fields: EntityOccurrenceFields): void { + requireOccurrenceIntent(fields.intent, fields.subject); + if (fields.dot[0] !== fields.eventOrder[1]) { + throw new WarpError( + 'EntityOccurrence Dot and EventId require the same writer', + 'E_ENTITY_OCCURRENCE_WRITER' + ); + } + requireNonEmptyString(fields.receiptWriter, 'entityOccurrence.receiptWriter'); + requireNonEmptyString(fields.worldline, 'entityOccurrence.worldline'); +} + +function freezeDot(dot: readonly [string, number]): readonly [string, number] { + const copy: [string, number] = [dot[0], dot[1]]; + return Object.freeze(copy); +} + +function freezeEventOrder( + eventOrder: readonly [number, string, string, number] +): readonly [number, string, string, number] { + const copy: [number, string, string, number] = [ + eventOrder[0], + eventOrder[1], + eventOrder[2], + eventOrder[3], + ]; + return Object.freeze(copy); +} + +function dotsEqual(left: readonly [string, number], right: readonly [string, number]): boolean { + return left[0] === right[0] && left[1] === right[1]; +} + +function containsDot(context: VersionVector, dot: readonly [string, number]): boolean { + return dot[1] <= (context.get(dot[0]) ?? 0); +} + +function compareEventOrder( + left: readonly [number, string, string, number], + right: readonly [number, string, string, number] +): number { + const comparisons = [ + compareNumber(left[0], right[0]), + compareString(left[1], right[1]), + compareString(left[2], right[2]), + compareNumber(left[3], right[3]), + ]; + return comparisons.find((comparison) => comparison !== 0) ?? 0; +} + +function compareNumber(left: number, right: number): number { + return left === right ? 0 : left < right ? -1 : 1; +} + +function compareString(left: string, right: string): number { + return left === right ? 0 : left < right ? -1 : 1; +} + +function requireOccurrenceIntent(intent: Intent, subject: string): void { + if (!(intent instanceof Intent) || intent.kind !== 'entity.add') { + throw new WarpError('EntityOccurrence requires an entity Intent', 'E_ENTITY_OCCURRENCE_INTENT'); + } + const { descriptor } = intent; + if ('subject' in descriptor && descriptor.subject !== subject) { + throw new WarpError( + 'EntityOccurrence subject does not match its issued Intent', + 'E_ENTITY_OCCURRENCE_SUBJECT' + ); + } +} + +function requireReceiptBinding(matches: boolean): void { + if (!matches) { + throw new WarpError( + 'EntityOccurrence does not belong to this WriteReceipt', + 'E_ENTITY_OCCURRENCE_RECEIPT_MISMATCH' + ); + } +} + +function distinctRelation( + leftObservedRight: boolean, + rightObservedLeft: boolean +): Exclude { + if (leftObservedRight && rightObservedLeft) { + throw new WarpError( + 'Distinct entity occurrences cannot causally observe each other', + 'E_ENTITY_OCCURRENCE_CAUSAL_CYCLE' + ); + } + if (leftObservedRight) { + return 'after'; + } + return rightObservedLeft ? 'before' : 'concurrent'; +} + +/** Stable opaque encoding of git-warp's worldline-scoped event coordinate. */ +function entityOccurrenceId( + worldline: string, + eventOrder: readonly [number, string, string, number] +): string { + return `occurrence:${hexEncode(textEncode(canonicalStringify([worldline, ...eventOrder])))}`; +} diff --git a/src/domain/api/EntityOccurrenceRuntime.ts b/src/domain/api/EntityOccurrenceRuntime.ts new file mode 100644 index 000000000..1aba3f069 --- /dev/null +++ b/src/domain/api/EntityOccurrenceRuntime.ts @@ -0,0 +1,68 @@ +import { Dot } from '../crdt/Dot.ts'; +import VersionVector from '../crdt/VersionVector.ts'; +import WarpError from '../errors/WarpError.ts'; +import { EventId } from '../utils/EventId.ts'; +import EntityOccurrence from './EntityOccurrence.ts'; +import type Evidence from './Evidence.ts'; +import type Intent from './Intent.ts'; + +type EntityOccurrenceReceiptBinding = { + readonly evidence: Evidence; + readonly intent: Intent; + readonly lane: string; + readonly writer: string; +}; + +type EntityOccurrenceFields = { + readonly context: VersionVector | Readonly>; + readonly dot: Dot; + readonly evidence: Evidence; + readonly eventId: EventId; + readonly intent: Intent; + readonly receiptWriter: string; + readonly subject: string; + readonly worldline: string; +}; + +/** Issues an occurrence without retaining ambient runtime state. */ +export function createEntityOccurrence(fields: EntityOccurrenceFields): EntityOccurrence { + requireCoordinateFields(fields); + return EntityOccurrence.issue({ + context: VersionVector.serialize(VersionVector.from(fields.context)), + dot: Object.freeze([fields.dot.writerId, fields.dot.counter]), + evidence: fields.evidence, + eventOrder: Object.freeze([ + fields.eventId.lamport, + fields.eventId.writerId, + fields.eventId.patchSha, + fields.eventId.opIndex, + ]), + intent: fields.intent, + receiptWriter: fields.receiptWriter, + subject: fields.subject, + worldline: fields.worldline, + }); +} + +/** Requires the causal coordinate owned by a substrate-issued occurrence. */ +export function requireIssuedEntityOccurrence( + occurrence: EntityOccurrence, + receipt: EntityOccurrenceReceiptBinding +): EntityOccurrence { + return EntityOccurrence.requireReceiptBinding(occurrence, receipt); +} + +function requireCoordinateFields(fields: EntityOccurrenceFields): void { + if (!(fields.dot instanceof Dot)) { + throw new WarpError('EntityOccurrence requires a Dot', 'E_ENTITY_OCCURRENCE_DOT'); + } + if (!(fields.eventId instanceof EventId)) { + throw new WarpError('EntityOccurrence requires an EventId', 'E_ENTITY_OCCURRENCE_EVENT'); + } + if (fields.dot.writerId !== fields.eventId.writerId) { + throw new WarpError( + 'EntityOccurrence Dot and EventId require the same writer', + 'E_ENTITY_OCCURRENCE_WRITER' + ); + } +} diff --git a/src/domain/api/EvidenceRuntime.ts b/src/domain/api/EvidenceRuntime.ts index ecc8b021d..6a652136b 100644 --- a/src/domain/api/EvidenceRuntime.ts +++ b/src/domain/api/EvidenceRuntime.ts @@ -29,9 +29,7 @@ const INDEX_SUPPORT = 'index'; const RECOVERY_EVIDENCE = 'recovery'; const RETENTION_SUPPORT = 'retention'; -export async function createWriteEvidence( - fields: WriteEvidenceFields, -): Promise { +export async function createWriteEvidence(fields: WriteEvidenceFields): Promise { const { runtime, context, patchSha, retentionWitness } = fields; const evidence = { basis: await createHandle(context, [ @@ -113,6 +111,9 @@ export async function createReadEvidence( export function freezeEvidence(evidence: Evidence, field: string): Evidence { assertEvidenceObject(evidence, field); + if (isCanonicalEvidence(evidence)) { + return evidence; + } const basis = freezeHandle(evidence.basis, `${field}.basis`); const support = freezeSupport(evidence.support, `${field}.support`); const retention = freezeRetentionEvidence(evidence.retention, `${field}.retention`); @@ -165,6 +166,89 @@ function assertEvidenceObject(evidence: Evidence, field: string): void { } } +function isCanonicalEvidence(evidence: Evidence): boolean { + return [ + isFrozenPlainObject(evidence), + hasOnlyKeys(evidence, canonicalEvidenceKeys(evidence)), + isCanonicalHandle(evidence.basis), + isCanonicalArray(evidence.support, isCanonicalHandle), + isCanonicalRetentionCollection(evidence.retention), + isCanonicalOptionalTick(evidence.tick), + ].every(Boolean); +} + +function canonicalEvidenceKeys(evidence: Evidence): string[] { + return [ + 'basis', + 'support', + ...(evidence.retention === undefined ? [] : ['retention']), + ...(evidence.tick === undefined ? [] : ['tick']), + ]; +} + +function isCanonicalHandle(handle: EvidenceHandle): boolean { + if (typeof handle !== 'object' || handle === null) { + return false; + } + return [ + isFrozenPlainObject(handle), + hasOnlyKeys(handle, ['id']), + typeof handle.id === 'string', + handle.id.length > 0, + ].every(Boolean); +} + +function isCanonicalRetentionEvidence(evidence: RetentionEvidence): boolean { + if (!(evidence instanceof RetentionEvidence)) { + return false; + } + return [ + Object.isFrozen(evidence), + hasOnlyKeys(evidence, ['witness', 'policy', 'reachability', 'rootKind']), + isCanonicalHandle(evidence.witness), + RetentionEvidence.hasValidFields(evidence), + ].every(Boolean); +} + +function isCanonicalRetentionCollection( + retention: readonly RetentionEvidence[] | undefined +): boolean { + return retention === undefined ? true : isCanonicalArray(retention, isCanonicalRetentionEvidence); +} + +function isCanonicalOptionalTick(tick: Tick | undefined): boolean { + return tick === undefined ? true : isCanonicalTick(tick); +} + +function isCanonicalTick(tick: Tick): boolean { + if (!(tick instanceof Tick)) { + return false; + } + return [Object.isFrozen(tick), hasOnlyKeys(tick, ['id', 'timeline'])].every(Boolean); +} + +function isCanonicalArray(values: readonly T[], isCanonical: (value: T) => boolean): boolean { + if (!Array.isArray(values)) { + return false; + } + return [ + Object.isFrozen(values), + Object.keys(values).length === values.length, + values.every(isCanonical), + ].every(Boolean); +} + +function isFrozenPlainObject(value: object): boolean { + return Object.isFrozen(value) && Object.getPrototypeOf(value) === Object.prototype; +} + +function hasOnlyKeys(value: object, expected: readonly string[]): boolean { + const keys = Object.keys(value); + return [keys.length === expected.length, keys.every((key) => expected.includes(key))].every( + Boolean + ); +} + function freezeSupport( support: readonly EvidenceHandle[], field: string @@ -177,7 +261,7 @@ function freezeSupport( function freezeRetentionEvidence( retention: readonly RetentionEvidence[] | undefined, - field: string, + field: string ): readonly RetentionEvidence[] | undefined { if (retention === undefined) { return undefined; @@ -188,7 +272,7 @@ function freezeRetentionEvidence( function assertRetentionEvidenceArray( retention: readonly RetentionEvidence[], - field: string, + field: string ): void { if (!Array.isArray(retention)) { throw new WarpError(`${field} must be an array`, 'E_RECEIPT_EVIDENCE'); @@ -197,25 +281,27 @@ function assertRetentionEvidenceArray( function freezeRetentionEvidenceEntries( retention: readonly RetentionEvidence[], - field: string, + field: string ): readonly RetentionEvidence[] { - return Object.freeze(retention.map((entry, index) => { - const itemField = `${field}[${index}]`; - if (entry === null || typeof entry !== 'object') { - throw new WarpError(`${itemField} must be retention evidence`, 'E_RECEIPT_EVIDENCE'); - } - return new RetentionEvidence({ - witness: freezeHandle(entry.witness, `${itemField}.witness`), - policy: entry.policy, - reachability: entry.reachability, - rootKind: entry.rootKind, - }); - })); + return Object.freeze( + retention.map((entry, index) => { + const itemField = `${field}[${index}]`; + if (entry === null || typeof entry !== 'object') { + throw new WarpError(`${itemField} must be retention evidence`, 'E_RECEIPT_EVIDENCE'); + } + return new RetentionEvidence({ + witness: freezeHandle(entry.witness, `${itemField}.witness`), + policy: entry.policy, + reachability: entry.reachability, + rootKind: entry.rootKind, + }); + }) + ); } async function createRetentionEvidence( context: ApiRuntimeContext, - witness: StorageRetentionWitness, + witness: StorageRetentionWitness ): Promise { return new RetentionEvidence({ witness: await createHandle(context, [ diff --git a/src/domain/api/Intent.ts b/src/domain/api/Intent.ts index 33608ada0..c76c4b7f6 100644 --- a/src/domain/api/Intent.ts +++ b/src/domain/api/Intent.ts @@ -1,13 +1,54 @@ import WarpError from '../errors/WarpError.ts'; +import { + isEntityCapturePayloadRecord, + type EntityCapturePayload, +} from '../types/EntityCapturePayload.ts'; import { copyPropValue, isPropValue, type PropValue } from '../types/PropValue.ts'; import { requireNonEmptyString } from '../utils/scalarValidation.ts'; -export type IntentKind = 'node.add' | 'node.remove' | 'edge.add' | 'edge.remove' | 'property.set'; +export type IntentKind = + | 'node.add' + | 'node.remove' + | 'edge.add' + | 'edge.remove' + | 'property.set' + | 'entity.add'; export type NodeIntentFields = { readonly subject: string; }; +/** + * One entity and its initial payload, stated as a single fact. + * + * The lowered patch declares an empty read set and exactly one subject write. + * That is a guarantee about the encoded patch operands, not proof that caller + * code made no prior graph read before choosing the subject or payload. A + * caller may supply a semantic subject, or ask git-warp to allocate one from + * the NodeAdd's writer-local dot with `addEntityAuto`. + * See `docs/READINGS_AND_OPTICS.md` §4. + * + * A payload is mandatory, so this intent cannot itself produce the empty shell + * that later property writes fill in. It does not withdraw `property.set`, so + * whether an entity stays immutable after creation is a law the application + * adopts rather than one this intent enforces. Payload *completeness* is + * likewise an application schema concern: the substrate checks that properties + * exist, not which ones an entity requires. Subject identity, occurrence + * identity, causal relation, deterministic event order, and application time + * remain separate concepts. + */ +type EntityPayloadFields = { + readonly properties: EntityCapturePayload; +}; + +export type EntityIntentFields = EntityPayloadFields & { + readonly subject: string; +}; + +export type AutoEntityIntentFields = EntityPayloadFields & { + readonly namespace: string; +}; + export type EdgeIntentFields = { readonly from: string; readonly to: string; @@ -25,13 +66,16 @@ export type IntentDescriptor = | (NodeIntentFields & { readonly kind: 'node.remove' }) | (EdgeIntentFields & { readonly kind: 'edge.add' }) | (EdgeIntentFields & { readonly kind: 'edge.remove' }) - | (PropertyIntentFields & { readonly kind: 'property.set' }); + | (PropertyIntentFields & { readonly kind: 'property.set' }) + | (EntityIntentFields & { readonly kind: 'entity.add' }) + | (AutoEntityIntentFields & { readonly kind: 'entity.add' }); const NODE_ADD: 'node.add' = 'node.add'; const NODE_REMOVE: 'node.remove' = 'node.remove'; const EDGE_ADD: 'edge.add' = 'edge.add'; const EDGE_REMOVE: 'edge.remove' = 'edge.remove'; const PROPERTY_SET: 'property.set' = 'property.set'; +const ENTITY_ADD: 'entity.add' = 'entity.add'; export default class Intent { readonly #descriptor: IntentDescriptor; @@ -61,6 +105,14 @@ export default class Intent { return new Intent(propertyDescriptor(fields)); } + static addEntity(fields: EntityIntentFields): Intent { + return new Intent(entityDescriptor(fields)); + } + + static addEntityAuto(fields: AutoEntityIntentFields): Intent { + return new Intent(entityDescriptor(fields)); + } + get kind(): IntentKind { return this.#descriptor.kind; } @@ -91,6 +143,9 @@ function normalizeKnownDescriptor(descriptor: IntentDescriptor): IntentDescripto if (descriptor.kind === PROPERTY_SET) { return propertyDescriptor(descriptor); } + if (descriptor.kind === ENTITY_ADD) { + return entityDescriptor(descriptor); + } throw new WarpError('Intent kind is unsupported', 'E_INTENT_KIND'); } @@ -143,6 +198,86 @@ function propertyDescriptor(fields: PropertyIntentFields): IntentDescriptor { }); } +function entityDescriptor(fields: EntityIntentFields | AutoEntityIntentFields): IntentDescriptor { + const checkedFields = requireIntentFields(fields); + const propertiesInput = requireIntentFields(checkedFields.properties); + if (!isEntityCapturePayloadRecord(propertiesInput)) { + throw new WarpError( + 'Intent entity payload must be a property record', + 'E_INTENT_ENTITY_PAYLOAD' + ); + } + const entries = Object.entries(propertiesInput); + if (entries.length === 0) { + throw new WarpError('Intent entity requires at least one property', 'E_INTENT_ENTITY_EMPTY'); + } + // Sorted so that payloads differing only in construction order describe the + // same entity, and a null prototype so that a caller-controlled key such as + // `__proto__` stays ordinary data. + const properties = nullPrototypePropertyMap( + entries.sort(compareEntityKeys).map(normalizeEntityProperty) + ); + const identity = entityIdentity(checkedFields); + return Object.freeze({ kind: ENTITY_ADD, ...identity, properties: Object.freeze(properties) }); +} + +function entityIdentity( + fields: EntityIntentFields | AutoEntityIntentFields +): Readonly<{ subject: string }> | Readonly<{ namespace: string }> { + const subject = 'subject' in fields ? fields.subject : undefined; + const namespace = 'namespace' in fields ? fields.namespace : undefined; + const hasSubject = subject !== undefined; + const hasNamespace = namespace !== undefined; + if (hasSubject === hasNamespace) { + throw new WarpError( + 'Intent entity requires exactly one of subject or namespace', + 'E_INTENT_ENTITY_IDENTITY' + ); + } + if (hasSubject) { + return Object.freeze({ subject: entityIdentityValue(subject, 'intent.subject') }); + } + return Object.freeze({ namespace: entityIdentityValue(namespace, 'intent.namespace') }); +} + +function entityIdentityValue(value: string | undefined, name: string): string { + if (value === undefined) { + throw new WarpError( + 'Intent entity requires exactly one of subject or namespace', + 'E_INTENT_ENTITY_IDENTITY' + ); + } + requireNonEmptyString(value, name); + return value; +} + +function normalizeEntityProperty([key, value]: readonly [string, PropValue]): readonly [ + string, + PropValue, +] { + requireNonEmptyString(key, 'intent.properties key'); + return [key, requireIntentValue(value)]; +} + +/** A property map with no prototype, so hostile keys stay ordinary data. */ +function nullPrototypePropertyMap( + entries: Iterable +): Record { + const properties: Record = Object.fromEntries(entries); + Object.setPrototypeOf(properties, null); + return properties; +} + +function compareEntityKeys( + [left]: readonly [string, PropValue], + [right]: readonly [string, PropValue] +): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +} + function requireIntentFields(fields: TFields | null | undefined): TFields { if (fields === null || fields === undefined) { throw new WarpError('Intent fields are required', 'E_INTENT_FIELDS'); diff --git a/src/domain/api/IntentBuilders.ts b/src/domain/api/IntentBuilders.ts index 50df884a4..7fd56dc34 100644 --- a/src/domain/api/IntentBuilders.ts +++ b/src/domain/api/IntentBuilders.ts @@ -1,5 +1,7 @@ import Intent, { + type AutoEntityIntentFields, type EdgeIntentFields, + type EntityIntentFields, type NodeIntentFields, type PropertyIntentFields, } from './Intent.ts'; @@ -9,6 +11,10 @@ export type IntentBuilders = { readonly add: (fields: NodeIntentFields) => Intent; readonly remove: (fields: NodeIntentFields) => Intent; }; + readonly entity: { + readonly add: (fields: EntityIntentFields) => Intent; + readonly addAuto: (fields: AutoEntityIntentFields) => Intent; + }; readonly edge: { readonly add: (fields: EdgeIntentFields) => Intent; readonly remove: (fields: EdgeIntentFields) => Intent; @@ -23,6 +29,10 @@ export const intent: IntentBuilders = Object.freeze({ add: (fields: NodeIntentFields) => Intent.addNode(fields), remove: (fields: NodeIntentFields) => Intent.removeNode(fields), }), + entity: Object.freeze({ + add: (fields: EntityIntentFields) => Intent.addEntity(fields), + addAuto: (fields: AutoEntityIntentFields) => Intent.addEntityAuto(fields), + }), edge: Object.freeze({ add: (fields: EdgeIntentFields) => Intent.addEdge(fields), remove: (fields: EdgeIntentFields) => Intent.removeEdge(fields), diff --git a/src/domain/api/IntentRuntime.ts b/src/domain/api/IntentRuntime.ts index bcb1b463d..42748d556 100644 --- a/src/domain/api/IntentRuntime.ts +++ b/src/domain/api/IntentRuntime.ts @@ -1,6 +1,6 @@ import type { PatchBuilder } from '../services/PatchBuilder.ts'; import type Patch from '../types/Patch.ts'; -import { isPropValue } from '../types/PropValue.ts'; +import { isPropValue, type PropValue } from '../types/PropValue.ts'; import Intent, { type IntentDescriptor, type IntentKind } from './Intent.ts'; import WarpError from '../errors/WarpError.ts'; import type { PatchOp } from '../types/ops/unions.ts'; @@ -13,6 +13,7 @@ const lowerers: ReadonlyMap = new Map([ ['edge.add', lowerEdgeAdd], ['edge.remove', lowerEdgeRemove], ['property.set', lowerPropertySet], + ['entity.add', lowerEntityAdd], ]); export function applyIntentToPatch(intent: Intent, patch: PatchBuilder): void { @@ -29,6 +30,10 @@ export function intentFromPatch(patch: Patch): Intent { if (isCascadingNodeRemoval(patch.ops, terminal)) { return Intent.removeNode({ subject: terminal.node }); } + const entity = entityIntent(patch); + if (entity !== null) { + return entity; + } if (patch.ops.length !== 1) { throw hydrationError('persisted Runtime intent patch has multiple operations'); } @@ -45,17 +50,99 @@ function requireTerminalOperation(patch: Patch): PatchOp { function isCascadingNodeRemoval( operations: readonly PatchOp[], - terminal: PatchOp, + terminal: PatchOp ): terminal is Extract { - return terminal.type === 'NodeRemove' - && operations.slice(0, -1) - .every((operation) => - operation.type === 'EdgeRemove' - && ( - operation.from === terminal.node - || operation.to === terminal.node - ) - ); + return ( + terminal.type === 'NodeRemove' && + operations + .slice(0, -1) + .every( + (operation) => + operation.type === 'EdgeRemove' && + (operation.from === terminal.node || operation.to === terminal.node) + ) + ); +} + +/** + * Recovers an entity capture: one NodeAdd carrying its own payload. + * + * Operation shape alone is not sufficient evidence. The patch must also + * declare the entity-capture footprint — an empty read set and a write set that + * is exactly the created subject — because a legacy `PropSet` sequence can + * present the same operations while recording a self-read. This recognition is + * syntactic classification only; it does not prove that application code made + * no prior graph read before constructing the patch payload. A patch whose + * recorded footprint does not match is not recognised here and falls through + * to the one-operation rule. + */ +function entityIntent(patch: Patch): Intent | null { + const [leading, ...payload] = patch.ops; + if (leading === undefined || leading.type !== 'NodeAdd') { + return null; + } + return entityIntentFor(patch, leading.node, payload); +} + +function entityIntentFor( + patch: Patch, + subject: string, + payload: readonly PatchOp[] +): Intent | null { + if (payload.length === 0 || !declaresEntityFootprint(patch, subject)) { + return null; + } + const properties = entityPayload(subject, payload); + return properties === null ? null : Intent.addEntity({ subject, properties }); +} + +/** Whether the patch records reads {} and writes exactly {subject}. */ +function declaresEntityFootprint(patch: Patch, subject: string): boolean { + const writes = patch.writes ?? []; + return (patch.reads ?? []).length === 0 && writes.length === 1 && writes[0] === subject; +} + +function entityPayload( + subject: string, + payload: readonly PatchOp[] +): Record | null { + const properties = new Map(); + for (const operation of payload) { + if (!isNodePropertyOperation(operation) || operation.node !== subject) { + return null; + } + admitEntityProperty(properties, operation); + } + return nullPrototypePropertyMap(properties); +} + +function admitEntityProperty( + properties: Map, + operation: Extract +): void { + if (properties.has(operation.key)) { + throw hydrationError( + 'persisted Runtime entity Intent sets the same property key more than once' + ); + } + if (!isPropValue(operation.value)) { + throw hydrationError('persisted Runtime entity Intent has an invalid value'); + } + properties.set(operation.key, operation.value); +} + +function nullPrototypePropertyMap( + entries: Iterable +): Record { + const properties: Record = Object.fromEntries(entries); + Object.setPrototypeOf(properties, null); + return properties; +} + +function isNodePropertyOperation( + operation: PatchOp +): operation is Extract { + return operation.type === 'NodePropSet' || operation.type === 'PropSet'; } function intentFromOperation(operation: PatchOp): Intent { @@ -72,7 +159,7 @@ function intentFromOperation(operation: PatchOp): Intent { return property; } throw hydrationError( - `persisted Runtime intent patch uses unsupported operation ${operation.type}`, + `persisted Runtime intent patch uses unsupported operation ${operation.type}` ); } @@ -80,9 +167,7 @@ function nodeIntent(operation: PatchOp): Intent | null { if (operation.type === 'NodeAdd') { return Intent.addNode({ subject: operation.node }); } - return operation.type === 'NodeRemove' - ? Intent.removeNode({ subject: operation.node }) - : null; + return operation.type === 'NodeRemove' ? Intent.removeNode({ subject: operation.node }) : null; } function edgeIntent(operation: PatchOp): Intent | null { @@ -94,9 +179,7 @@ function edgeIntent(operation: PatchOp): Intent | null { to: operation.to, label: operation.label, }; - return operation.type === 'EdgeAdd' - ? Intent.addEdge(fields) - : Intent.removeEdge(fields); + return operation.type === 'EdgeAdd' ? Intent.addEdge(fields) : Intent.removeEdge(fields); } function propertyIntent(operation: PatchOp): Intent | null { @@ -142,6 +225,15 @@ function lowerPropertySet(descriptor: IntentDescriptor, patch: PatchBuilder): vo patch.setProperty(descriptor.subject, descriptor.key, descriptor.value); } +function lowerEntityAdd(descriptor: IntentDescriptor, patch: PatchBuilder): void { + assertDescriptorKind(descriptor, 'entity.add'); + if ('subject' in descriptor) { + patch.addEntity(descriptor.subject, descriptor.properties); + return; + } + patch.addEntityAuto(descriptor.namespace, descriptor.properties); +} + function assertDescriptorKind( descriptor: IntentDescriptor, kind: K diff --git a/src/domain/api/RetentionEvidence.ts b/src/domain/api/RetentionEvidence.ts index 676c23217..59f132d48 100644 --- a/src/domain/api/RetentionEvidence.ts +++ b/src/domain/api/RetentionEvidence.ts @@ -28,6 +28,15 @@ export default class RetentionEvidence { this.rootKind = requireRootKind(options.rootKind); Object.freeze(this); } + + /** Reports whether the storage-neutral retention fields satisfy this type's invariants. */ + static hasValidFields(options: RetentionEvidenceOptions): boolean { + return ( + isPolicy(options.policy) && + isReachability(options.reachability) && + isRootKind(options.rootKind) + ); + } } function requireOptions(options: RetentionEvidenceOptions): void { @@ -47,29 +56,43 @@ function freezeWitness(witness: EvidenceHandle): EvidenceHandle { } function requirePolicy(policy: StorageRetentionPolicy): StorageRetentionPolicy { - if (policy !== 'pinned' && policy !== 'evictable') { + if (!isPolicy(policy)) { throw evidenceError('policy is invalid'); } return policy; } function requireReachability(reachability: StorageReachability): StorageReachability { - if (reachability !== 'anchored' && reachability !== 'orphaned' && reachability !== 'volatile') { + if (!isReachability(reachability)) { throw evidenceError('reachability is invalid'); } return reachability; } function requireRootKind(rootKind: StorageRetentionRootKind): StorageRetentionRootKind { - if (rootKind !== 'root-set' - && rootKind !== 'publication' - && rootKind !== 'cache-set' - && rootKind !== 'expiring-set') { + if (!isRootKind(rootKind)) { throw evidenceError('rootKind is invalid'); } return rootKind; } +function isPolicy(policy: string): policy is StorageRetentionPolicy { + return policy === 'pinned' || policy === 'evictable'; +} + +function isReachability(reachability: string): reachability is StorageReachability { + return reachability === 'anchored' || reachability === 'orphaned' || reachability === 'volatile'; +} + +function isRootKind(rootKind: string): rootKind is StorageRetentionRootKind { + return ( + rootKind === 'root-set' || + rootKind === 'publication' || + rootKind === 'cache-set' || + rootKind === 'expiring-set' + ); +} + function evidenceError(message: string): WarpError { return new WarpError(`Retention evidence ${message}`, 'E_RECEIPT_EVIDENCE'); } diff --git a/src/domain/api/WriteReceipt.ts b/src/domain/api/WriteReceipt.ts index b80e25b07..35482bd38 100644 --- a/src/domain/api/WriteReceipt.ts +++ b/src/domain/api/WriteReceipt.ts @@ -5,6 +5,8 @@ import { requireAdmissionOutcome } from './AdmissionOutcomeRuntime.ts'; import type Evidence from './Evidence.ts'; import { freezeEvidence } from './EvidenceRuntime.ts'; import Intent from './Intent.ts'; +import EntityOccurrence from './EntityOccurrence.ts'; +import { requireIssuedEntityOccurrence } from './EntityOccurrenceRuntime.ts'; import { freezeRepairHints, type RepairHint } from './ReceiptSupport.ts'; type WriteReceiptFields = { @@ -13,9 +15,15 @@ type WriteReceiptFields = { readonly intent: Intent; readonly outcome: AdmissionOutcome; readonly evidence: Evidence; + readonly occurrence?: EntityOccurrence; readonly repairHints?: readonly RepairHint[]; }; +type WriteReceiptOccurrenceFields = Pick< + WriteReceiptFields, + 'evidence' | 'intent' | 'lane' | 'occurrence' | 'outcome' | 'writer' +>; + export type WriteReceiptOptions = WriteReceiptFields; export default class WriteReceipt { @@ -23,6 +31,7 @@ export default class WriteReceipt { readonly intent: Intent; readonly operation: 'write' = 'write'; readonly outcome: AdmissionOutcome; + readonly occurrence: EntityOccurrence | undefined; readonly repairHints: readonly RepairHint[]; readonly reason: string | undefined; readonly lane: string; @@ -37,6 +46,7 @@ export default class WriteReceipt { this.intent = fields.intent; this.outcome = fields.outcome; this.evidence = freezeEvidence(fields.evidence, 'writeReceipt.evidence'); + this.occurrence = validateOccurrence(fields, this.evidence); this.repairHints = freezeRepairHints(fields.repairHints ?? []); this.reason = fields.outcome.kind === 'obstruction' ? fields.outcome.witness.reason.code : undefined; @@ -44,6 +54,41 @@ export default class WriteReceipt { } } +function validateOccurrence( + fields: WriteReceiptOccurrenceFields, + evidence: Evidence +): EntityOccurrence | undefined { + const admitted = fields.outcome.kind === 'derived' || fields.outcome.kind === 'plural'; + if (fields.intent.kind === 'entity.add' && admitted) { + return requireEntityOccurrence(fields.occurrence, { + evidence, + intent: fields.intent, + lane: fields.lane, + writer: fields.writer, + }); + } + if (fields.occurrence !== undefined) { + throw new WarpError( + 'Only an admitted entity WriteReceipt can carry an EntityOccurrence', + 'E_WRITE_RECEIPT_ENTITY_OCCURRENCE' + ); + } + return undefined; +} + +function requireEntityOccurrence( + occurrence: EntityOccurrence | undefined, + receipt: Pick +): EntityOccurrence { + if (!(occurrence instanceof EntityOccurrence)) { + throw new WarpError( + 'Admitted entity WriteReceipt requires an EntityOccurrence', + 'E_WRITE_RECEIPT_ENTITY_OCCURRENCE' + ); + } + return requireIssuedEntityOccurrence(occurrence, receipt); +} + function validateWriteReceiptFields(fields: WriteReceiptOptions): void { requireNonEmptyString(fields.lane, 'writeReceipt.lane'); requireNonEmptyString(fields.writer, 'writeReceipt.writer'); diff --git a/src/domain/api/WriteRuntime.ts b/src/domain/api/WriteRuntime.ts index 745a4d564..5cb993cd1 100644 --- a/src/domain/api/WriteRuntime.ts +++ b/src/domain/api/WriteRuntime.ts @@ -12,11 +12,18 @@ import { projectAdmissionOutcome } from './AdmissionOutcomeRuntime.ts'; import type Evidence from './Evidence.ts'; import { createWriteEvidence, createWriteRecoveryEvidence } from './EvidenceRuntime.ts'; import type Intent from './Intent.ts'; -import { applyIntentToPatch } from './IntentRuntime.ts'; +import { applyIntentToPatch, intentFromPatch } from './IntentRuntime.ts'; import type { RepairHint } from './ReceiptSupport.ts'; import WriteReceipt from './WriteReceipt.ts'; import type { PatchCommitResult } from '../types/PatchCommitResult.ts'; import type AdmissionEvaluation from '../admission/AdmissionEvaluation.ts'; +import type EntityOccurrence from './EntityOccurrence.ts'; +import { createEntityOccurrence } from './EntityOccurrenceRuntime.ts'; +import { Dot } from '../crdt/Dot.ts'; +import NodeAdd from '../types/ops/NodeAdd.ts'; +import { EventId } from '../utils/EventId.ts'; +import { entityCapturePayloadsEqual } from '../types/EntityCapturePayload.ts'; +import { allocateEntitySubject } from '../services/PatchBuilderEntity.ts'; import { createDerivedWriteAdmission, createObstructedWriteAdmission, @@ -167,17 +174,97 @@ async function derivedWriteReceipt( ): Promise { const { runtime, context, intent, publication } = fields; const evidence = await committedWriteEvidence(fields); + const occurrence = publishedEntityOccurrence(fields, evidence); const receipt = new WriteReceipt({ lane: runtime.worldlineName, writer: runtime.writerId, intent, outcome: projectAdmissionOutcome(createDerivedWriteAdmission(fields), evidence.basis), evidence, + ...(occurrence === undefined ? {} : { occurrence }), }); context.bindReceipt(receipt, { operation: 'write', patchSha: publication.sha }); return receipt; } +function publishedEntityOccurrence( + fields: PublishedWriteFields, + evidence: Evidence +): EntityOccurrence | undefined { + if (fields.intent.kind !== 'entity.add') { + return undefined; + } + const { patch, sha } = fields.publication; + const leading = patch.ops[0]; + if (!(leading instanceof NodeAdd) || !(leading.dot instanceof Dot)) { + throw entityOccurrenceError( + 'Published entity write does not begin with a causally identified NodeAdd' + ); + } + const subject = publishedEntitySubject(fields.intent, publishedEntityIntent(patch), leading.dot); + return createEntityOccurrence({ + context: patch.context, + dot: leading.dot, + evidence, + eventId: new EventId(patch.lamport, patch.writer, sha, 0), + intent: fields.intent, + receiptWriter: fields.runtime.writerId, + subject, + worldline: fields.runtime.worldlineName, + }); +} + +function publishedEntitySubject(requested: Intent, published: Intent, dot: Dot): string { + const publishedDescriptor = publishedEntityDescriptor(published); + const requestedDescriptor = requestedEntityDescriptor(requested); + requirePublishedEntityPayload(requestedDescriptor, publishedDescriptor); + const expectedSubject = + 'subject' in requestedDescriptor + ? requestedDescriptor.subject + : allocateEntitySubject(requestedDescriptor.namespace, dot); + if (publishedDescriptor.subject !== expectedSubject) { + throw entityOccurrenceError('Published entity write does not match the requested entity'); + } + return publishedDescriptor.subject; +} + +function publishedEntityDescriptor(published: Intent) { + const { descriptor } = published; + if (descriptor.kind !== 'entity.add' || !('subject' in descriptor)) { + throw entityOccurrenceError('Published entity write is not an entity capture'); + } + return descriptor; +} + +function requestedEntityDescriptor(requested: Intent) { + const { descriptor } = requested; + if (descriptor.kind !== 'entity.add') { + throw entityOccurrenceError('Requested write is not an entity capture'); + } + return descriptor; +} + +function requirePublishedEntityPayload( + requested: ReturnType, + published: ReturnType +): void { + if (!entityCapturePayloadsEqual(requested.properties, published.properties)) { + throw entityOccurrenceError('Published entity write does not match the requested payload'); + } +} + +function publishedEntityIntent(patch: PublishedWriteFields['publication']['patch']): Intent { + try { + return intentFromPatch(patch); + } catch { + throw entityOccurrenceError('Published entity write is not a complete entity capture'); + } +} + +function entityOccurrenceError(message: string): WarpError { + return new WarpError(message, 'E_WRITE_ENTITY_OCCURRENCE'); +} + async function committedWriteEvidence(fields: PublishedWriteFields): Promise { try { return await createWriteEvidence({ diff --git a/src/domain/crdt/Dot.ts b/src/domain/crdt/Dot.ts index 53a1332f2..816434180 100644 --- a/src/domain/crdt/Dot.ts +++ b/src/domain/crdt/Dot.ts @@ -57,6 +57,8 @@ import CrdtError from '../errors/CrdtError.ts'; +const CANONICAL_COUNTER = /^[1-9][0-9]*$/; + /** * Dot — unique operation identity for CRDT semantics. * A (writerId, counter) pair that serves as a "birth certificate" @@ -66,14 +68,14 @@ export class Dot { /** Writer identifier (non-empty string) */ readonly writerId: string; - /** Monotonic counter (positive integer) */ + /** Monotonic counter (positive safe integer) */ readonly counter: number; /** * Creates a validated Dot. * * @param writerId - Must be non-empty string - * @param counter - Must be positive integer (> 0) + * @param counter - Must be a positive safe integer (> 0) */ constructor(writerId: string, counter: number) { if (typeof writerId !== 'string' || writerId.length === 0) { @@ -83,8 +85,8 @@ export class Dot { }); } - if (!Number.isInteger(counter) || counter <= 0) { - throw new CrdtError('counter must be a positive integer', { + if (!Number.isSafeInteger(counter) || counter <= 0) { + throw new CrdtError('counter must be a positive safe integer', { code: 'E_CRDT_INVALID_COUNTER', context: { writerId, counter }, }); @@ -122,8 +124,7 @@ export class Dot { * * Writer IDs are parsed using lastIndexOf(':') as separator. Writer IDs * containing colons are supported because the counter (after the last colon) - * is always numeric. However, empty writer IDs or IDs ending with a colon - * may produce unexpected results. + * is a canonical positive decimal integer without a sign or leading zero. * * @param encoded - Format: "writerId:counter" */ @@ -138,7 +139,6 @@ export class Dot { const writerId = encoded.slice(0, lastColonIndex); const counterStr = encoded.slice(lastColonIndex + 1); - const counter = parseInt(counterStr, 10); if (writerId.length === 0) { throw new CrdtError('Invalid encoded dot format: empty writerId', { @@ -147,13 +147,14 @@ export class Dot { }); } - if (isNaN(counter) || counter <= 0) { + if (!CANONICAL_COUNTER.test(counterStr)) { throw new CrdtError('Invalid encoded dot format: invalid counter', { code: 'E_CRDT_INVALID_COUNTER', context: { encoded }, }); } + const counter = Number(counterStr); return new Dot(writerId, counter); } diff --git a/src/domain/crdt/VersionVector.ts b/src/domain/crdt/VersionVector.ts index 600c6a298..8b8e6ab8d 100644 --- a/src/domain/crdt/VersionVector.ts +++ b/src/domain/crdt/VersionVector.ts @@ -36,9 +36,9 @@ function _isValidWriterId(writerId: string): boolean { return typeof writerId === 'string' && writerId.length > 0; } -/** Checks if counter is a non-negative integer. */ +/** Checks if counter is a non-negative safe integer. */ function _isValidCounter(counter: number): boolean { - return typeof counter === 'number' && Number.isInteger(counter) && counter >= 0; + return typeof counter === 'number' && Number.isSafeInteger(counter) && counter >= 0; } /** Validates a (writerId, counter) entry. */ @@ -92,7 +92,9 @@ export default class VersionVector { * - A Map (validates and copies) * - A plain object {writerId: counter} (boundary parse — skips zero counters) */ - static from(source: VersionVector | Map | Record): VersionVector { + static from( + source: VersionVector | Map | Readonly> + ): VersionVector { if (source instanceof VersionVector) { return source.clone(); } @@ -119,7 +121,7 @@ export default class VersionVector { * Zero counters are elided: a counter of 0 carries no causal * information and wastes space. */ - static _fromPlainObject(source: Record): VersionVector { + static _fromPlainObject(source: Readonly>): VersionVector { const map = new Map(); for (const [writerId, counter] of Object.entries(source)) { _validateEntry(writerId, counter); @@ -136,21 +138,24 @@ export default class VersionVector { * type provides iteration, the codec decides the wire format. */ static serialize(vv: VersionVector): Record { - const obj: Record = {}; + const entries: [string, number][] = []; const sortedKeys = [...vv.keys()].sort(); for (const key of sortedKeys) { const val = vv.get(key); if (val === undefined || val === 0) { - throw new CrdtError(`VersionVector.serialize: zero counter for writerId "${key}" — VersionVector must not contain zero counters`, { - code: 'E_CRDT_ZERO_COUNTER', - context: { writerId: key }, - }); + throw new CrdtError( + `VersionVector.serialize: zero counter for writerId "${key}" — VersionVector must not contain zero counters`, + { + code: 'E_CRDT_ZERO_COUNTER', + context: { writerId: key }, + } + ); } - obj[key] = val; + entries.push([key, val]); } - return obj; + return Object.fromEntries(entries); } // --------------------------------------------------------------------------- @@ -167,7 +172,9 @@ export default class VersionVector { */ set(writerId: string, counter: number): this { if (Object.isFrozen(this)) { - throw new CrdtError('Cannot mutate a frozen VersionVector', { code: 'E_CRDT_FROZEN_MUTATION' }); + throw new CrdtError('Cannot mutate a frozen VersionVector', { + code: 'E_CRDT_FROZEN_MUTATION', + }); } _validateEntry(writerId, counter); if (counter === 0) { @@ -218,7 +225,9 @@ export default class VersionVector { */ increment(writerId: string): Dot { if (Object.isFrozen(this)) { - throw new CrdtError('Cannot mutate a frozen VersionVector', { code: 'E_CRDT_FROZEN_MUTATION' }); + throw new CrdtError('Cannot mutate a frozen VersionVector', { + code: 'E_CRDT_FROZEN_MUTATION', + }); } // Validate before mutating to avoid partial corruption const dot = new Dot(writerId, (this.#entries.get(writerId) ?? 0) + 1); diff --git a/src/domain/services/PatchBuilder.ts b/src/domain/services/PatchBuilder.ts index 645442399..5f55f265b 100644 --- a/src/domain/services/PatchBuilder.ts +++ b/src/domain/services/PatchBuilder.ts @@ -1,6 +1,5 @@ /** * PatchBuilder — fluent API for building schema:2 WARP patches. - * * Maintains a VersionVector per writer, assigns dots on add operations, * reads current state to populate observedDots for removes, and includes * context VersionVector in the patch. @@ -15,28 +14,21 @@ import NodeAdd from '../types/ops/NodeAdd.ts'; import NodeRemove from '../types/ops/NodeRemove.ts'; import EdgeAdd from '../types/ops/EdgeAdd.ts'; import EdgeRemove from '../types/ops/EdgeRemove.ts'; -import NodePropSet from '../types/ops/NodePropSet.ts'; -import EdgePropSet from '../types/ops/EdgePropSet.ts'; -import ContentAttachmentWriteIntent from '../graph/ContentAttachmentWriteIntent.ts'; -import EdgePropertyWriteIntent from '../graph/EdgePropertyWriteIntent.ts'; -import NodePropertyWriteIntent from '../graph/NodePropertyWriteIntent.ts'; import type { PatchOp, CanonicalPatchOp } 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 { encodeEdgeKey } from './KeyCodec.ts'; import { lowerCanonicalOp } from './OpNormalizer.ts'; -import WriterError from '../errors/WriterError.ts'; import PatchError from '../errors/PatchError.ts'; import { canonicalStringify } from '../utils/canonicalStringify.ts'; import { findAttachedData, assertNoReservedBytes, assertObservedDotsForRemove, + resolveEffectId, } from './PatchBuilderValidation.ts'; -import { - requirePatchPropertyValue, - storeContentAttachmentPayload, - type ContentInput, - type ContentMetadataInput, -} from './PatchBuilderContent.ts'; +import type { ContentInput, ContentMetadataInput } from './PatchBuilderContent.ts'; +import { allocateEntityCapture, planEntityCapturePayload } from './PatchBuilderEntity.ts'; +import PatchBuilderPropertyRuntime from './PatchBuilderPropertyRuntime.ts'; +import type { EntityCapturePayload } from '../types/EntityCapturePayload.ts'; import { capturePatchBuilderCausalBasis } from './admission/PatchBuilderCausalBasis.ts'; import { requireCommitMessageCodec } from './codec/CommitMessageCodecRequirement.ts'; import { commitPatch } from './PatchCommitter.ts'; @@ -84,15 +76,13 @@ export class PatchBuilder { private readonly _patchJournal: PatchJournalPort | null; private readonly _commitMessageCodec: CommitMessageCodecPort | null; private readonly _logger: LoggerPort; - private readonly _assetStorage: AssetStoragePort | null; + private readonly _properties: PatchBuilderPropertyRuntime; private readonly _ops: PatchOp[] = []; private readonly _nodesAdded = new Set(); private readonly _edgesAdded = new Set(); private readonly _observedOperands = new Set(); private readonly _writes = new Set(); - private readonly _contentAssets: AssetHandle[] = []; private _snapshotState: WarpState | null | undefined = undefined; - private _hasEdgeProps = false; private _committed = false; private _committing = false; @@ -100,8 +90,10 @@ export class PatchBuilder { this._persistence = options.persistence; this._graphName = options.graphName; this._writerId = options.writerId; - this._targetRefPath = typeof options.targetRefPath === 'string' && options.targetRefPath.length > 0 - ? options.targetRefPath : null; + this._targetRefPath = + typeof options.targetRefPath === 'string' && options.targetRefPath.length > 0 + ? options.targetRefPath + : null; this._lamport = options.lamport; this._vv = options.versionVector.clone(); this._getCurrentState = options.getCurrentState; @@ -111,17 +103,24 @@ export class PatchBuilder { this._patchJournal = options.patchJournal ?? null; this._commitMessageCodec = options.commitMessageCodec ?? null; this._logger = options.logger ?? nullLogger; - this._assetStorage = options.assetStorage ?? null; - capturePatchBuilderCausalBasis( - this, - { - graphName: options.graphName, - writerId: options.writerId, - participantId: options.admissionParticipantId ?? options.writerId, - expectedParentSha: this._expectedParentSha, - evaluationCoordinateRef: options.evaluationCoordinateRef ?? null, - } - ); + this._properties = new PatchBuilderPropertyRuntime({ + assetStorage: options.assetStorage ?? null, + assertMutable: () => this._assertNotCommitted(), + edgesAdded: this._edgesAdded, + getSnapshotState: () => this._getSnapshotState(), + graphName: this._graphName, + nodesAdded: this._nodesAdded, + observedOperands: this._observedOperands, + ops: this._ops, + writes: this._writes, + }); + capturePatchBuilderCausalBasis(this, { + graphName: options.graphName, + writerId: options.writerId, + participantId: options.admissionParticipantId ?? options.writerId, + expectedParentSha: this._expectedParentSha, + evaluationCoordinateRef: options.evaluationCoordinateRef ?? null, + }); } // ── State access ─────────────────────────────────────────────────── @@ -135,12 +134,13 @@ export class PatchBuilder { private _assertNotCommitted(): void { if (this._committed || this._committing) { - throw new PatchError('PatchBuilder already committed — create a new builder', { code: 'E_PATCH_ALREADY_COMMITTED' }); + throw new PatchError('PatchBuilder already committed — create a new builder', { + code: 'E_PATCH_ALREADY_COMMITTED', + }); } } // ── Graph operations ─────────────────────────────────────────────── - addNode(nodeId: string): PatchBuilder { this._assertNotCommitted(); assertNoReservedBytes(nodeId, 'nodeId'); @@ -151,6 +151,29 @@ export class PatchBuilder { return this; } + /** Creates one entity and its initial payload in a single-subject patch. */ + addEntity(nodeId: string, properties: EntityCapturePayload): PatchBuilder { + this._assertNotCommitted(); + const scope = { added: this._nodesAdded, state: this._getSnapshotState() }; + const payload = planEntityCapturePayload(nodeId, properties, scope); + this.addNode(nodeId); + this._ops.push(...payload); + return this; + } + addEntityAuto(namespace: string, properties: EntityCapturePayload): PatchBuilder { + this._assertNotCommitted(); + const capture = allocateEntityCapture({ + namespace, + properties, + scope: { added: this._nodesAdded, state: this._getSnapshotState() }, + writerId: this._writerId, + versionVector: this._vv, + }); + this._ops.push(new NodeAdd(capture.nodeId, capture.dot), ...capture.payload); + this._nodesAdded.add(capture.nodeId); + this._writes.add(capture.nodeId); + return this; + } removeNode(nodeId: string): PatchBuilder { this._assertNotCommitted(); const state = this._getSnapshotState(); @@ -160,7 +183,14 @@ export class PatchBuilder { for (const edgeKey of edges) { const parts = edgeKey.split('\0'); const edgeDots = [...state.edgeAlive.getDots(edgeKey)]; - this._ops.push(new EdgeRemove({ from: parts[0]!, to: parts[1]!, label: parts[2]!, observedDots: edgeDots })); + this._ops.push( + new EdgeRemove({ + from: parts[0]!, + to: parts[1]!, + label: parts[2]!, + observedDots: edgeDots, + }) + ); this._observedOperands.add(edgeKey); } } @@ -169,20 +199,27 @@ export class PatchBuilder { const { edges, props, hasData } = findAttachedData(state, nodeId); if (hasData) { const details: string[] = []; - if (edges.length > 0) { details.push(`${edges.length} edge(s)`); } - if (props.length > 0) { details.push(`${props.length} propert${props.length === 1 ? 'y' : 'ies'}`); } + if (edges.length > 0) { + details.push(`${edges.length} edge(s)`); + } + if (props.length > 0) { + details.push(`${props.length} propert${props.length === 1 ? 'y' : 'ies'}`); + } const summary = details.join(' and '); if (this._onDeleteWithData === 'reject') { throw new PatchError( `Cannot delete node '${nodeId}': node has attached data (${summary}). ` + - `Remove edges and properties first, or set onDeleteWithData to 'cascade'.`, - { code: 'E_PATCH_DELETE_WITH_DATA', context: { nodeId, edges: edges.length, props: props.length } }, + `Remove edges and properties first, or set onDeleteWithData to 'cascade'.`, + { + code: 'E_PATCH_DELETE_WITH_DATA', + context: { nodeId, edges: edges.length, props: props.length }, + } ); } if (this._onDeleteWithData === 'warn') { this._logger.warn( - `[warp] Deleting node '${nodeId}' which has attached data (${summary}). Orphaned data will remain in state.`, + `[warp] Deleting node '${nodeId}' which has attached data (${summary}). Orphaned data will remain in state.` ); } } @@ -191,7 +228,7 @@ export class PatchBuilder { if (!state) { throw new PatchError( `Cannot remove node '${nodeId}': graph must be materialized before removing nodes`, - { code: 'E_PATCH_NO_STATE' }, + { code: 'E_PATCH_NO_STATE' } ); } const observedDots = [...state.nodeAlive.getDots(nodeId)]; @@ -223,7 +260,7 @@ export class PatchBuilder { if (!state) { throw new PatchError( `Cannot remove edge '${from}->${to}' (${label}): graph must be materialized before removing edges`, - { code: 'E_PATCH_NO_STATE' }, + { code: 'E_PATCH_NO_STATE' } ); } const observedDots = [...state.edgeAlive.getDots(edgeKey)]; @@ -233,16 +270,13 @@ export class PatchBuilder { return this; } - emitEffect(kind: string, payload?: unknown, options?: { effectId?: string }): string { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B + emitEffect(kind: string, payload?: T, options?: { effectId?: string }): string { this._assertNotCommitted(); - if (typeof kind !== 'string' || kind.length === 0) { - throw new PatchError('emitEffect: kind must be a non-empty string', { - code: 'E_EFFECT_INVALID_KIND', context: { kind }, - }); - } - const effectId = (options?.effectId !== undefined && options.effectId !== '') - ? options.effectId - : `${EFFECT_NODE_PREFIX}${this._writerId}-${this._lamport}-${this._ops.length}`; + const effectId = resolveEffectId(kind, options?.effectId, { + writerId: this._writerId, + lamport: this._lamport, + sequence: this._ops.length, + }); this.addNode(effectId); this.setProperty(effectId, 'kind', kind); this.setProperty(effectId, 'writer', this._writerId); @@ -252,36 +286,15 @@ export class PatchBuilder { return effectId; } - setProperty(nodeId: string, key: string, value: unknown): PatchBuilder { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B + setProperty(nodeId: string, key: string, value: T): PatchBuilder { this._assertNotCommitted(); - assertNoReservedBytes(nodeId, 'nodeId'); - assertNoReservedBytes(key, 'key'); - const intent = NodePropertyWriteIntent.fromLegacyProperty( - nodeId, - key, - requirePatchPropertyValue(value), - ); - this._lowerNodePropertyIntent(intent); + this._properties.setNodeProperty(nodeId, key, value); return this; } - setEdgeProperty(from: string, to: string, label: string, key: string, value: unknown): PatchBuilder { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B + setEdgeProperty(from: string, to: string, label: string, key: string, value: T): PatchBuilder { 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, - label, - key, - value: requirePatchPropertyValue(value), - }); - const ek = this._assertEdgeExists(from, to, label); - this._lowerEdgePropertyIntent(intent); - this._observedOperands.add(ek); - this._writes.add(ek); + this._properties.setEdgeProperty({ from, to, label, key, value }); return this; } @@ -290,149 +303,41 @@ export class PatchBuilder { async attachContent( nodeId: string, content: ContentInput, - metadata?: ContentMetadataInput, + metadata?: ContentMetadataInput ): Promise { this._assertNotCommitted(); - assertNoReservedBytes(nodeId, 'nodeId'); - assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key'); - this._assertNodeExistsForContent(nodeId); - if (this._assetStorage === null) { - throw new WriterError('Cannot attach content without asset storage', { code: 'NO_ASSET_STORAGE' }); - } - const slug = `${this._graphName}/${nodeId}`; - const payload = await storeContentAttachmentPayload({ - assetStorage: this._assetStorage, - content, - metadata, - slug, - }); - const intent = ContentAttachmentWriteIntent.forNode(nodeId, payload); - this._lowerNodeContentIntent(intent); - this._contentAssets.push(intent.handle()); + await this._properties.attachNodeContent(nodeId, content, metadata); return this; } clearContent(nodeId: string): PatchBuilder { this._assertNotCommitted(); - assertNoReservedBytes(nodeId, 'nodeId'); - assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key'); - this._assertNodeExistsForContent(nodeId); - this.setProperty(nodeId, CONTENT_PROPERTY_KEY, null); - this.setProperty(nodeId, CONTENT_SIZE_PROPERTY_KEY, null); - this.setProperty(nodeId, CONTENT_MIME_PROPERTY_KEY, null); + this._properties.clearNodeContent(nodeId); return this; } async attachEdgeContent( - from: string, to: string, label: string, + from: string, + to: string, + label: string, content: ContentInput, - metadata?: ContentMetadataInput, + metadata?: ContentMetadataInput ): Promise { this._assertNotCommitted(); - assertNoReservedBytes(from, 'from'); - assertNoReservedBytes(to, 'to'); - assertNoReservedBytes(label, 'label'); - assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key'); - this._assertEdgeExists(from, to, label); - if (this._assetStorage === null) { - throw new WriterError('Cannot attach content without asset storage', { code: 'NO_ASSET_STORAGE' }); - } - const slug = `${this._graphName}/${from}/${to}/${label}`; - const payload = await storeContentAttachmentPayload({ - assetStorage: this._assetStorage, - content, - metadata, - slug, - }); - const intent = ContentAttachmentWriteIntent.forEdge({ from, to, label }, payload); - this._lowerEdgeContentIntent(intent); - this._contentAssets.push(intent.handle()); + await this._properties.attachEdgeContent({ from, to, label, content, metadata }); return this; } clearEdgeContent(from: string, to: string, label: string): PatchBuilder { this._assertNotCommitted(); - assertNoReservedBytes(from, 'from'); - assertNoReservedBytes(to, 'to'); - assertNoReservedBytes(label, 'label'); - assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key'); - this._assertEdgeExists(from, to, label); - this.setEdgeProperty(from, to, label, CONTENT_PROPERTY_KEY, null); - this.setEdgeProperty(from, to, label, CONTENT_SIZE_PROPERTY_KEY, null); - this.setEdgeProperty(from, to, label, CONTENT_MIME_PROPERTY_KEY, null); + this._properties.clearEdgeContent(from, to, label); return this; } - private _lowerNodeContentIntent(intent: ContentAttachmentWriteIntent): void { - const nodeId = intent.nodeId(); - this.setProperty(nodeId, CONTENT_PROPERTY_KEY, intent.handle().toString()); - this.setProperty(nodeId, CONTENT_SIZE_PROPERTY_KEY, intent.size()); - this.setProperty(nodeId, CONTENT_MIME_PROPERTY_KEY, intent.mime()); - } - - private _lowerEdgeContentIntent(intent: ContentAttachmentWriteIntent): void { - const target = intent.edgeTarget(); - this.setEdgeProperty( - target.from, - target.to, - target.label, - CONTENT_PROPERTY_KEY, - intent.handle().toString(), - ); - this.setEdgeProperty(target.from, target.to, target.label, CONTENT_SIZE_PROPERTY_KEY, intent.size()); - 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 { - if (this._nodesAdded.has(nodeId)) { return; } - const state = this._getSnapshotState(); - if (!state || !state.nodeAlive.contains(nodeId)) { - throw new PatchError( - `Cannot attach content to unknown node '${nodeId}': add the node first`, // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - { code: 'E_PATCH_CONTENT_UNKNOWN_NODE', context: { nodeId } }, - ); - } - } - - private _assertEdgeExists(from: string, to: string, label: string): string { - const ek = encodeEdgeKey(from, to, label); - if (!this._edgesAdded.has(ek)) { - const state = this._getSnapshotState(); - if (!state || !state.edgeAlive.contains(ek)) { - throw new PatchError( - `Cannot set property on unknown edge (${from} → ${to} [${label}]): add the edge first`, // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - { code: 'E_PATCH_EDGE_PROP_UNKNOWN_EDGE', context: { from, to, label } }, - ); - } - } - return ek; - } - // ── Build & Commit ───────────────────────────────────────────────── build(): Patch { - const schema = this._hasEdgeProps ? 3 : 2; + const schema = this._properties.hasEdgeProperties ? 3 : 2; const rawOps = this._ops.map((op) => lowerCanonicalOp(op as CanonicalPatchOp)); return new Patch({ schema, @@ -463,10 +368,10 @@ export class PatchBuilder { ops: this._ops, observedOperands: this._observedOperands, writes: this._writes, - hasEdgeProps: this._hasEdgeProps, + hasEdgeProps: this._properties.hasEdgeProperties, expectedParentSha: this._expectedParentSha, targetRefPath: this._targetRefPath, - contentAssets: this._contentAssets, + contentAssets: this._properties.contentAssets, patchJournal: this._patchJournal, commitMessageCodec: requireCommitMessageCodec(this._commitMessageCodec), logger: this._logger, @@ -481,13 +386,23 @@ export class PatchBuilder { // ── Accessors ────────────────────────────────────────────────────── - get ops(): PatchOp[] { return this._ops; } - get versionVector(): VersionVector { return this._vv; } - get reads(): ReadonlySet { return new Set(this._observedOperands); } - get writes(): ReadonlySet { return new Set(this._writes); } + get ops(): PatchOp[] { + return this._ops; + } + get versionVector(): VersionVector { + return this._vv; + } + get reads(): ReadonlySet { + return new Set(this._observedOperands); + } + get writes(): ReadonlySet { + return new Set(this._writes); + } /** * Asset handles captured via content attachment operations. */ - get contentAssets(): readonly AssetHandle[] { return [...this._contentAssets]; } + get contentAssets(): readonly AssetHandle[] { + return this._properties.contentAssets; + } } diff --git a/src/domain/services/PatchBuilderContent.ts b/src/domain/services/PatchBuilderContent.ts index 671ece1ee..1b19d9b30 100644 --- a/src/domain/services/PatchBuilderContent.ts +++ b/src/domain/services/PatchBuilderContent.ts @@ -3,6 +3,7 @@ import ContentAttachmentMime from '../graph/ContentAttachmentMime.ts'; import ContentAttachmentPayload from '../graph/ContentAttachmentPayload.ts'; import ContentAttachmentSize from '../graph/ContentAttachmentSize.ts'; import PatchError from '../errors/PatchError.ts'; +import WriterError from '../errors/WriterError.ts'; import type AssetStoragePort from '../../ports/AssetStoragePort.ts'; import type { AssetWriteOptions } from '../../ports/AssetStoragePort.ts'; import { isPropValue, type PropValue } from '../types/PropValue.ts'; @@ -23,6 +24,11 @@ export type StoreContentAttachmentPayloadOptions = { readonly slug: string; }; +export type StageContentAttachmentOptions = Omit< + StoreContentAttachmentPayloadOptions, + 'assetStorage' +> & { readonly assetStorage: AssetStoragePort | null }; + /** Validates public patch property values before intent construction. */ export function requirePatchPropertyValue(value: T): PropValue { if (isPropValue(value)) { @@ -33,13 +39,31 @@ export function requirePatchPropertyValue(value: T): PropValue { }); } +/** + * Requires asset storage, then stages one content attachment. + * + * Shared by node and edge attachment so the storage precondition is stated + * once rather than duplicated per target shape. + */ +export async function stageContentAttachment( + options: StageContentAttachmentOptions +): Promise { + const { assetStorage } = options; + if (assetStorage === null) { + throw new WriterError('Cannot attach content without asset storage', { + code: 'NO_ASSET_STORAGE', + }); + } + return await storeContentAttachmentPayload({ ...options, assetStorage }); +} + export async function storeContentAttachmentPayload( - options: StoreContentAttachmentPayloadOptions, + options: StoreContentAttachmentPayloadOptions ): Promise { const metadata = contentMetadata(options.content, options.metadata); const staged = await options.assetStorage.stage( normalizeToAsyncIterable(options.content), - assetWriteOptions(options.slug, metadata.expectedSize), + assetWriteOptions(options.slug, metadata.expectedSize) ); return new ContentAttachmentPayload({ handle: new ContentAttachmentHandle(staged.handle.toString()), @@ -50,7 +74,7 @@ export async function storeContentAttachmentPayload( function contentMetadata( content: ContentInput, - metadata: ContentMetadataInput | undefined, + metadata: ContentMetadataInput | undefined ): { readonly mime: string | null; readonly expectedSize: number | null } { if (!isStreamingInput(content)) { const normalized = normalizeContentMetadata(content, metadata); @@ -59,9 +83,10 @@ function contentMetadata( return streamingContentMetadata(metadata); } -function streamingContentMetadata( - metadata: ContentMetadataInput | undefined, -): { readonly mime: string | null; readonly expectedSize: number | null } { +function streamingContentMetadata(metadata: ContentMetadataInput | undefined): { + readonly mime: string | null; + readonly expectedSize: number | null; +} { return { mime: optionalMime(metadataMime(metadata)), expectedSize: optionalSize(metadataSize(metadata)), @@ -84,9 +109,6 @@ function optionalSize(value: number | null): number | null { return value === null ? null : new ContentAttachmentSize(value).toNumber(); } -function assetWriteOptions( - slug: string, - expectedSize: number | null, -): AssetWriteOptions { +function assetWriteOptions(slug: string, expectedSize: number | null): AssetWriteOptions { return { slug, filename: 'content', expectedSize }; } diff --git a/src/domain/services/PatchBuilderEntity.ts b/src/domain/services/PatchBuilderEntity.ts new file mode 100644 index 000000000..4b291544c --- /dev/null +++ b/src/domain/services/PatchBuilderEntity.ts @@ -0,0 +1,186 @@ +/** + * Entity capture — the single-subject, single-patch shape. + * + * One patch creates the entity and carries its initial payload. Unlike + * `addNode` followed by `setProperty`, that patch records no self-read: the + * `NodeAdd` in the same patch brings the node into existence. Its declared + * footprint is therefore `reads` empty and `writes` exactly the subject. That + * declaration describes encoded operands only; caller code may still have read + * graph state before choosing the subject or payload. An auto-allocated subject + * gives the entity an initial singleton cone; a supplied semantic subject may + * deliberately collect more than one causally distinct occurrence. + * + * Three limits, stated because the shape is easy to over-read: + * + * - **Initial, not complete.** This module enforces a non-empty payload. Which + * fields make an entity *complete* is an application schema concern; the + * substrate cannot know it. + * - **Creation, not lifetime.** An allocated subject's cone is a singleton + * until something else writes the id. `property.set` and `node.remove` + * remain available, so an immutable-entity lifetime is a law an application + * must adopt, not one this constructor imposes. + * - **Subject guard, not occurrence identity.** The local uniqueness + * guard refuses ids the builder can see, and a lane writer can see nothing. + * See {@link assertEntityAbsent}. + * + * See `docs/READINGS_AND_OPTICS.md` §4 and §8. + * + * @module domain/services/PatchBuilderEntity + */ + +import PatchError from '../errors/PatchError.ts'; +import { Dot } from '../crdt/Dot.ts'; +import type VersionVector from '../crdt/VersionVector.ts'; +import NodePropertyWriteIntent from '../graph/NodePropertyWriteIntent.ts'; +import NodePropSet from '../types/ops/NodePropSet.ts'; +import type { PropValue } from '../types/PropValue.ts'; +import { + isEntityCapturePayloadRecord, + type EntityCapturePayload, +} from '../types/EntityCapturePayload.ts'; +import type { WarpState } from './JoinReducer.ts'; +import { requirePatchPropertyValue } from './PatchBuilderContent.ts'; +import { assertNoReservedBytes } from './PatchBuilderValidation.ts'; +import { hexEncode, textEncode } from '../utils/bytes.ts'; + +/** Where an id may already exist: earlier in this patch, or in the graph. */ +export type EntityCaptureScope = { + readonly added: ReadonlySet; + readonly state: WarpState | null; +}; + +/** + * Allocates an application-namespaced subject from the NodeAdd's own dot. + * + * The representation is deliberately opaque to callers. Its only contract is + * uniqueness under the substrate's writer-id/dot invariant; applications must + * not parse it or use its bytes as a causal or chronological coordinate. + */ +export function allocateEntitySubject(namespace: string, dot: Dot): string { + if (typeof namespace !== 'string' || namespace.length === 0) { + throw new PatchError('Entity allocation namespace must be a non-empty string', { + code: 'E_PATCH_ENTITY_NAMESPACE', + }); + } + assertNoReservedBytes(namespace, 'entity allocation namespace'); + if (!(dot instanceof Dot)) { + throw new PatchError('Entity allocation requires a Dot', { + code: 'E_PATCH_ENTITY_ALLOCATION_DOT', + }); + } + return `${namespace}:${hexEncode(textEncode(Dot.encode(dot)))}`; +} + +/** Validates, allocates, and advances the writer-local dot exactly once. */ +export function allocateEntityCapture(fields: { + readonly namespace: string; + readonly properties: EntityCapturePayload; + readonly scope: EntityCaptureScope; + readonly writerId: string; + readonly versionVector: VersionVector; +}): Readonly<{ dot: Dot; nodeId: string; payload: readonly NodePropSet[] }> { + const { namespace, properties, scope, writerId, versionVector } = fields; + const expectedDot = new Dot(writerId, (versionVector.get(writerId) ?? 0) + 1); + const nodeId = allocateEntitySubject(namespace, expectedDot); + const payload = planEntityCapturePayload(nodeId, properties, scope); + const dot = versionVector.increment(writerId); + if (!Dot.equals(expectedDot, dot)) { + throw new PatchError('Entity allocation diverged from the writer-local dot', { + code: 'E_PATCH_ENTITY_ALLOCATION_DIVERGED', + }); + } + return Object.freeze({ dot, nodeId, payload }); +} + +/** + * Validates one entity capture and returns its payload operations. + * + * Every check runs before a single operation is produced, so a rejected + * entity leaves the caller's patch untouched. + * + * @param nodeId - the entity subject, supplied or substrate-allocated + * @param properties - the non-empty initial payload + * @param scope - the ids already spoken for by this patch and the graph + */ +export function planEntityCapturePayload( + nodeId: string, + properties: EntityCapturePayload, + scope: EntityCaptureScope +): readonly NodePropSet[] { + assertNoReservedBytes(nodeId, 'nodeId'); + const entries = requirePayloadEntries(nodeId, properties); + assertEntityAbsent(nodeId, scope); + return entries.map(([key, value]) => entityProperty(nodeId, key, value)); +} + +function requirePayloadEntries( + nodeId: string, + properties: EntityCapturePayload +): readonly (readonly [string, PropValue])[] { + requirePayloadRecord(nodeId, properties); + const entries = Object.entries(properties); + if (entries.length === 0) { + throw new PatchError( + `Cannot capture entity '${nodeId}' without a payload: an entity created empty is a shell, not a fact`, + { code: 'E_PATCH_ENTITY_EMPTY', context: { nodeId } } + ); + } + // Key order is not evidence. Sorting keeps two payloads that differ only in + // construction order lowering to byte-identical operations. + return entries.sort(([left], [right]) => (left === right ? 0 : left < right ? -1 : 1)); +} + +function requirePayloadRecord(nodeId: string, properties: EntityCapturePayload): void { + if (!isEntityCapturePayloadRecord(properties)) { + throw invalidPayloadError(nodeId); + } +} + +function invalidPayloadError(nodeId: string): PatchError { + return new PatchError('Entity payload must be a property record', { + code: 'E_PATCH_ENTITY_PAYLOAD', + context: { nodeId }, + }); +} + +/** + * Refuses an id the builder can already see. + * + * "Can see" is the whole promise: an id added earlier in this same patch, or + * one alive in the materialized basis the builder was opened against. + * + * **On the `Runtime` → `Lane.write` path this guard never fires**, because a + * lane writer has neither. `Runtime` exposes no materialization, so the basis + * is always null; and one intent lowers to one patch, with validation running + * before the node is added, so nothing precedes the entity in its own patch + * either. Both arms are therefore unreachable there — measured, not inferred: + * see `test/integration/application/Runtime.entityCapture.concurrent.test.ts`, + * where one writer re-creates the same id on one lane and is admitted. + * + * What remains is a guard for a direct `PatchBuilder` opened against a + * materialized state, which is the advanced and testing surface. It catches a + * mistake a caller could have seen; it is not uniqueness, and it is not a + * race detector. Applications that truly mean one creation per semantic + * subject must enforce that domain invariant separately. Applications with no + * independent semantic key should use substrate allocation instead of + * maintaining a shadow counter. + */ +function assertEntityAbsent(nodeId: string, scope: EntityCaptureScope): void { + if (!scope.added.has(nodeId) && !(scope.state?.nodeAlive.contains(nodeId) ?? false)) { + return; + } + throw new PatchError(`Cannot capture entity '${nodeId}': this writer can already see that id`, { + code: 'E_PATCH_ENTITY_EXISTS', + context: { nodeId }, + }); +} + +function entityProperty(nodeId: string, key: string, value: PropValue): NodePropSet { + assertNoReservedBytes(key, 'key'); + const intent = NodePropertyWriteIntent.fromLegacyProperty( + nodeId, + key, + requirePatchPropertyValue(value) + ); + return new NodePropSet(nodeId, intent.propertyKey(), intent.propertyValue()); +} diff --git a/src/domain/services/PatchBuilderPropertyRuntime.ts b/src/domain/services/PatchBuilderPropertyRuntime.ts new file mode 100644 index 000000000..051d9c88a --- /dev/null +++ b/src/domain/services/PatchBuilderPropertyRuntime.ts @@ -0,0 +1,225 @@ +import type AssetStoragePort from '../../ports/AssetStoragePort.ts'; +import ContentAttachmentWriteIntent from '../graph/ContentAttachmentWriteIntent.ts'; +import EdgePropertyWriteIntent from '../graph/EdgePropertyWriteIntent.ts'; +import NodePropertyWriteIntent from '../graph/NodePropertyWriteIntent.ts'; +import type AssetHandle from '../storage/AssetHandle.ts'; +import EdgePropSet from '../types/ops/EdgePropSet.ts'; +import NodePropSet from '../types/ops/NodePropSet.ts'; +import type { PatchOp } from '../types/ops/unions.ts'; +import PatchError from '../errors/PatchError.ts'; +import { + CONTENT_MIME_PROPERTY_KEY, + CONTENT_PROPERTY_KEY, + CONTENT_SIZE_PROPERTY_KEY, + encodeEdgeKey, +} from './KeyCodec.ts'; +import { + requirePatchPropertyValue, + stageContentAttachment, + type ContentInput, + type ContentMetadataInput, +} from './PatchBuilderContent.ts'; +import { assertNoReservedBytes } from './PatchBuilderValidation.ts'; +import type { WarpState } from './JoinReducer.ts'; + +type PatchBuilderPropertyRuntimeOptions = { + readonly assetStorage: AssetStoragePort | null; + readonly assertMutable: () => void; + readonly edgesAdded: ReadonlySet; + readonly getSnapshotState: () => WarpState | null; + readonly graphName: string; + readonly nodesAdded: ReadonlySet; + readonly observedOperands: Set; + readonly ops: PatchOp[]; + readonly writes: Set; +}; + +type EdgePropertyInput = { + readonly from: string; + readonly to: string; + readonly label: string; + readonly key: string; + readonly value: T; +}; + +type EdgeContentInput = { + readonly from: string; + readonly to: string; + readonly label: string; + readonly content: ContentInput; + readonly metadata: ContentMetadataInput | undefined; +}; + +/** Owns property and content-attachment lowering for one PatchBuilder. */ +export default class PatchBuilderPropertyRuntime { + readonly #options: PatchBuilderPropertyRuntimeOptions; + readonly #contentAssets: AssetHandle[] = []; + #hasEdgeProperties = false; + + constructor(options: PatchBuilderPropertyRuntimeOptions) { + this.#options = Object.freeze({ ...options }); + } + + get hasEdgeProperties(): boolean { + return this.#hasEdgeProperties; + } + + get contentAssets(): AssetHandle[] { + return [...this.#contentAssets]; + } + + setNodeProperty(nodeId: string, key: string, value: T): void { + assertNoReservedBytes(nodeId, 'nodeId'); + assertNoReservedBytes(key, 'key'); + const intent = NodePropertyWriteIntent.fromLegacyProperty( + nodeId, + key, + requirePatchPropertyValue(value) + ); + this.#lowerNodePropertyIntent(intent); + } + + setEdgeProperty(input: EdgePropertyInput): void { + const { from, to, label, key, value } = input; + assertNoReservedBytes(from, 'from node ID'); + assertNoReservedBytes(to, 'to node ID'); + assertNoReservedBytes(label, 'edge label'); + assertNoReservedBytes(key, 'key'); + const intent = EdgePropertyWriteIntent.fromLegacyProperty({ + from, + to, + label, + key, + value: requirePatchPropertyValue(value), + }); + const edgeKey = this.#assertEdgeExists(from, to, label); + this.#lowerEdgePropertyIntent(intent); + this.#options.observedOperands.add(edgeKey); + this.#options.writes.add(edgeKey); + } + + async attachNodeContent( + nodeId: string, + content: ContentInput, + metadata?: ContentMetadataInput + ): Promise { + assertNoReservedBytes(nodeId, 'nodeId'); + assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key'); + this.#assertNodeExistsForContent(nodeId); + const payload = await stageContentAttachment({ + assetStorage: this.#options.assetStorage, + slug: `${this.#options.graphName}/${nodeId}`, + content, + metadata, + }); + this.#options.assertMutable(); + const intent = ContentAttachmentWriteIntent.forNode(nodeId, payload); + this.#lowerNodeContentIntent(intent); + this.#contentAssets.push(intent.handle()); + } + + clearNodeContent(nodeId: string): void { + assertNoReservedBytes(nodeId, 'nodeId'); + assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key'); + this.#assertNodeExistsForContent(nodeId); + this.setNodeProperty(nodeId, CONTENT_PROPERTY_KEY, null); + this.setNodeProperty(nodeId, CONTENT_SIZE_PROPERTY_KEY, null); + this.setNodeProperty(nodeId, CONTENT_MIME_PROPERTY_KEY, null); + } + + async attachEdgeContent(input: EdgeContentInput): Promise { + const { from, to, label, content, metadata } = input; + assertNoReservedBytes(from, 'from'); + assertNoReservedBytes(to, 'to'); + assertNoReservedBytes(label, 'label'); + assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key'); + this.#assertEdgeExists(from, to, label); + const payload = await stageContentAttachment({ + assetStorage: this.#options.assetStorage, + slug: `${this.#options.graphName}/${from}/${to}/${label}`, + content, + metadata, + }); + this.#options.assertMutable(); + const intent = ContentAttachmentWriteIntent.forEdge({ from, to, label }, payload); + this.#lowerEdgeContentIntent(intent); + this.#contentAssets.push(intent.handle()); + } + + clearEdgeContent(from: string, to: string, label: string): void { + assertNoReservedBytes(from, 'from'); + assertNoReservedBytes(to, 'to'); + assertNoReservedBytes(label, 'label'); + assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key'); + this.#assertEdgeExists(from, to, label); + this.setEdgeProperty({ from, to, label, key: CONTENT_PROPERTY_KEY, value: null }); + this.setEdgeProperty({ from, to, label, key: CONTENT_SIZE_PROPERTY_KEY, value: null }); + this.setEdgeProperty({ from, to, label, key: CONTENT_MIME_PROPERTY_KEY, value: null }); + } + + #lowerNodeContentIntent(intent: ContentAttachmentWriteIntent): void { + const nodeId = intent.nodeId(); + this.setNodeProperty(nodeId, CONTENT_PROPERTY_KEY, intent.handle().toString()); + this.setNodeProperty(nodeId, CONTENT_SIZE_PROPERTY_KEY, intent.size()); + this.setNodeProperty(nodeId, CONTENT_MIME_PROPERTY_KEY, intent.mime()); + } + + #lowerEdgeContentIntent(intent: ContentAttachmentWriteIntent): void { + const target = intent.edgeTarget(); + this.setEdgeProperty({ + ...target, + key: CONTENT_PROPERTY_KEY, + value: intent.handle().toString(), + }); + this.setEdgeProperty({ ...target, key: CONTENT_SIZE_PROPERTY_KEY, value: intent.size() }); + this.setEdgeProperty({ ...target, key: CONTENT_MIME_PROPERTY_KEY, value: intent.mime() }); + } + + #lowerNodePropertyIntent(intent: NodePropertyWriteIntent): void { + const nodeId = intent.nodeId(); + this.#options.ops.push(new NodePropSet(nodeId, intent.propertyKey(), intent.propertyValue())); + this.#options.observedOperands.add(nodeId); + this.#options.writes.add(nodeId); + } + + #lowerEdgePropertyIntent(intent: EdgePropertyWriteIntent): void { + const target = intent.edgeTarget(); + this.#options.ops.push( + new EdgePropSet({ + from: target.from, + to: target.to, + label: target.label, + key: intent.propertyKey(), + value: intent.propertyValue(), + }) + ); + this.#hasEdgeProperties = true; + } + + #assertNodeExistsForContent(nodeId: string): void { + if (this.#options.nodesAdded.has(nodeId)) { + return; + } + const state = this.#options.getSnapshotState(); + if (!state || !state.nodeAlive.contains(nodeId)) { + throw new PatchError( + `Cannot attach content to unknown node '${nodeId}': add the node first`, // nosemgrep: ts-no-unknown-outside-adapters -- 0025B + { code: 'E_PATCH_CONTENT_UNKNOWN_NODE', context: { nodeId } } + ); + } + } + + #assertEdgeExists(from: string, to: string, label: string): string { + const edgeKey = encodeEdgeKey(from, to, label); + if (!this.#options.edgesAdded.has(edgeKey)) { + const state = this.#options.getSnapshotState(); + if (!state || !state.edgeAlive.contains(edgeKey)) { + throw new PatchError( + `Cannot set property on unknown edge (${from} → ${to} [${label}]): add the edge first`, // nosemgrep: ts-no-unknown-outside-adapters -- 0025B + { code: 'E_PATCH_EDGE_PROP_UNKNOWN_EDGE', context: { from, to, label } } + ); + } + } + return edgeKey; + } +} diff --git a/src/domain/services/PatchBuilderValidation.ts b/src/domain/services/PatchBuilderValidation.ts index 132d52a90..503163b64 100644 --- a/src/domain/services/PatchBuilderValidation.ts +++ b/src/domain/services/PatchBuilderValidation.ts @@ -5,18 +5,38 @@ * @module domain/services/PatchBuilderValidation */ -import { FIELD_SEPARATOR, EDGE_PROP_PREFIX } from './KeyCodec.ts'; +import { FIELD_SEPARATOR, EDGE_PROP_PREFIX, EFFECT_NODE_PREFIX } from './KeyCodec.ts'; import PatchError from '../errors/PatchError.ts'; import type { WarpState } from './JoinReducer.ts'; import WarpStateClass from './state/WarpState.ts'; +/** + * Validates an effect kind and resolves the node id the effect is recorded + * under, falling back to a writer-scoped derivation when none was requested. + */ +export function resolveEffectId( + kind: string, + requestedId: string | undefined, + origin: { readonly writerId: string; readonly lamport: number; readonly sequence: number } +): string { + if (typeof kind !== 'string' || kind.length === 0) { + throw new PatchError('emitEffect: kind must be a non-empty string', { + code: 'E_EFFECT_INVALID_KIND', + context: { kind }, + }); + } + return requestedId !== undefined && requestedId !== '' + ? requestedId + : `${EFFECT_NODE_PREFIX}${origin.writerId}-${origin.lamport}-${origin.sequence}`; +} + /** * Inspects materialized state for edges and properties attached to a node. * Used by `removeNode` to detect attached data before deletion. */ export function findAttachedData( state: WarpState, - nodeId: string, + nodeId: string ): { edges: string[]; props: string[]; hasData: boolean } { const edges: string[] = []; const props: string[] = []; @@ -45,21 +65,21 @@ export function findAttachedData( */ export function assertNoReservedBytes(value: string, label: string): void { if (typeof value !== 'string') { - throw new PatchError( - `${label} must be a string, got ${typeof value}`, - { code: 'E_PATCH_IDENTIFIER_TYPE', context: { label, actual: typeof value } }, - ); + throw new PatchError(`${label} must be a string, got ${typeof value}`, { + code: 'E_PATCH_IDENTIFIER_TYPE', + context: { label, actual: typeof value }, + }); } if (value.includes(FIELD_SEPARATOR)) { throw new PatchError( `${label} must not contain null bytes (\\0): ${JSON.stringify(value)}`, // nosemgrep: ts-no-json-stringify-in-core -- 0025B - { code: 'E_PATCH_IDENTIFIER_NULL_BYTE', context: { label } }, + { code: 'E_PATCH_IDENTIFIER_NULL_BYTE', context: { label } } ); } if (value.length > 0 && value[0] === EDGE_PROP_PREFIX) { throw new PatchError( `${label} must not start with reserved prefix \\x01: ${JSON.stringify(value)}`, // nosemgrep: ts-no-json-stringify-in-core -- 0025B - { code: 'E_PATCH_IDENTIFIER_RESERVED_PREFIX', context: { label } }, + { code: 'E_PATCH_IDENTIFIER_RESERVED_PREFIX', context: { label } } ); } } @@ -67,13 +87,15 @@ export function assertNoReservedBytes(value: string, label: string): void { export function assertObservedDotsForRemove( observedDots: readonly string[], targetKind: 'node' | 'edge', - context: { readonly nodeId?: string; readonly edgeKey?: string }, + context: { readonly nodeId?: string; readonly edgeKey?: string } ): void { - if (observedDots.length > 0) { return; } + if (observedDots.length > 0) { + return; + } const target = targetKind === 'node' ? context.nodeId : context.edgeKey; throw new PatchError( `Cannot remove missing ${targetKind} '${target ?? 'unresolved'}': entity is not alive in current state`, - { code: 'E_PATCH_ENTITY_NOT_FOUND', context: { targetKind, ...context } }, + { code: 'E_PATCH_ENTITY_NOT_FOUND', context: { targetKind, ...context } } ); } @@ -91,28 +113,30 @@ export function byteSizeOfContent(content: Uint8Array | string): number { */ export function normalizeContentMetadata( content: Uint8Array | string, - metadata: { mime?: string | null; size?: number | null } | undefined, + metadata: { mime?: string | null; size?: number | null } | undefined ): { mime: string | null; size: number } { - if (metadata !== undefined && (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata))) { - throw new PatchError( - 'content metadata must be an object when provided', - { code: 'E_PATCH_CONTENT_METADATA_TYPE' }, - ); + if ( + metadata !== undefined && + (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) + ) { + throw new PatchError('content metadata must be an object when provided', { + code: 'E_PATCH_CONTENT_METADATA_TYPE', + }); } const actualSize = byteSizeOfContent(content); const providedSize = metadata?.size; if (providedSize !== undefined && providedSize !== null) { if (!Number.isInteger(providedSize) || providedSize < 0) { - throw new PatchError( - 'content metadata size must be a non-negative integer', - { code: 'E_PATCH_CONTENT_SIZE_TYPE', context: { providedSize } }, - ); + throw new PatchError('content metadata size must be a non-negative integer', { + code: 'E_PATCH_CONTENT_SIZE_TYPE', + context: { providedSize }, + }); } if (providedSize !== actualSize) { throw new PatchError( `content metadata size ${providedSize} does not match actual byte size ${actualSize}`, - { code: 'E_PATCH_CONTENT_SIZE_MISMATCH', context: { providedSize, actualSize } }, + { code: 'E_PATCH_CONTENT_SIZE_MISMATCH', context: { providedSize, actualSize } } ); } } @@ -120,10 +144,9 @@ export function normalizeContentMetadata( const providedMime = metadata?.mime; if (providedMime !== undefined && providedMime !== null) { if (typeof providedMime !== 'string' || providedMime.trim() === '') { - throw new PatchError( - 'content metadata mime must be a non-empty string when provided', - { code: 'E_PATCH_CONTENT_MIME_TYPE' }, - ); + throw new PatchError('content metadata mime must be a non-empty string when provided', { + code: 'E_PATCH_CONTENT_MIME_TYPE', + }); } } diff --git a/src/domain/types/EntityCapturePayload.ts b/src/domain/types/EntityCapturePayload.ts new file mode 100644 index 000000000..f412829ec --- /dev/null +++ b/src/domain/types/EntityCapturePayload.ts @@ -0,0 +1,36 @@ +import { propValuesEqual, type PropValue } from './PropValue.ts'; + +/** Property record carried by one single-patch entity capture. */ +export type EntityCapturePayload = Readonly>; + +/** Whether an entity payload has a plain or null-prototype record boundary. */ +export function isEntityCapturePayloadRecord(properties: EntityCapturePayload): boolean { + if (properties === null || typeof properties !== 'object' || Array.isArray(properties)) { + return false; + } + const prototype = Reflect.getPrototypeOf(properties); + return prototype === Object.prototype || prototype === null; +} + +/** Exact equality over normalized entity property records. */ +export function entityCapturePayloadsEqual( + left: EntityCapturePayload, + right: EntityCapturePayload +): boolean { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + if (leftKeys.length !== rightKeys.length) { + return false; + } + return leftKeys.every((key, index) => { + const rightKey = rightKeys[index]; + const leftValue = left[key]; + const rightValue = right[key]; + return ( + rightKey === key && + leftValue !== undefined && + rightValue !== undefined && + propValuesEqual(leftValue, rightValue) + ); + }); +} diff --git a/src/domain/types/PropValue.ts b/src/domain/types/PropValue.ts index b406c68fe..9555a4298 100644 --- a/src/domain/types/PropValue.ts +++ b/src/domain/types/PropValue.ts @@ -100,6 +100,99 @@ export function copyPropValue(value: PropValue): PropValue { return copyCompositePropValue(value); } +/** Exact recursive equality for property-register values. */ +export function propValuesEqual(left: PropValue, right: PropValue): boolean { + if (Object.is(left, right)) { + return true; + } + const byteEquality = propValueByteEquality(left, right); + if (byteEquality !== null) { + return byteEquality; + } + const arrayEquality = propValueArrayEquality(left, right); + if (arrayEquality !== null) { + return arrayEquality; + } + return propValueRecordEquality(left, right); +} + +function propValueByteEquality(left: PropValue, right: PropValue): boolean | null { + if (!(left instanceof Uint8Array)) { + return null; + } + return right instanceof Uint8Array ? propValueBytesEqual(left, right) : false; +} + +function propValueBytesEqual(left: Uint8Array, right: Uint8Array): boolean { + return ( + left.byteLength === right.byteLength && left.every((value, index) => value === right[index]) + ); +} + +function propValueArrayEquality(left: PropValue, right: PropValue): boolean | null { + if (!Array.isArray(left)) { + return null; + } + return Array.isArray(right) ? propValueArraysEqual(left, right) : false; +} + +function propValueArraysEqual(left: PropValue[], right: PropValue[]): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (!propValueArrayEntriesEqual(left[index], right[index])) { + return false; + } + } + return true; +} + +function propValueArrayEntriesEqual( + left: PropValue | undefined, + right: PropValue | undefined +): boolean { + return left !== undefined && right !== undefined && propValuesEqual(left, right); +} + +function isPropValueRecord(value: PropValue): value is { [key: string]: PropValue } { + return ( + value !== null && + typeof value === 'object' && + !(value instanceof Uint8Array) && + !Array.isArray(value) + ); +} + +function propValueRecordEquality(left: PropValue, right: PropValue): boolean { + if (!isPropValueRecord(left)) { + return false; + } + return isPropValueRecord(right) ? propValueRecordsEqual(left, right) : false; +} + +function propValueRecordsEqual( + left: { [key: string]: PropValue }, + right: { [key: string]: PropValue } +): boolean { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + if (leftKeys.length !== rightKeys.length) { + return false; + } + return leftKeys.every((key, index) => { + const rightKey = rightKeys[index]; + const leftValue = left[key]; + const rightValue = right[key]; + return ( + rightKey === key && + leftValue !== undefined && + rightValue !== undefined && + propValuesEqual(leftValue, rightValue) + ); + }); +} + function copyCompositePropValue( value: Uint8Array | PropValue[] | { [key: string]: PropValue } ): PropValue { diff --git a/test/fixtures/generated-sdk/README.md b/test/fixtures/generated-sdk/README.md index 8eb9552fc..1e436b477 100644 --- a/test/fixtures/generated-sdk/README.md +++ b/test/fixtures/generated-sdk/README.md @@ -11,10 +11,9 @@ Regenerate with Wesley `0.3.0-alpha.1`: npm run generate:sdk-fixture ``` -CI installs Wesley from commit -`4891a631f888c5b2f70e117e3704538dd1362c2f`, rejects byte drift in both -generated files, compiles them against the packed package, and runs the SDK -against a disposable real-Git repository. +CI installs `wesley-cli` version `0.3.0-alpha.1` from crates.io, rejects byte +drift in both generated files, compiles them against the packed package, and +runs the SDK against a disposable real-Git repository. The fixture contains source files only. Its Git repository, package install, checkpoint, and runtime data are created under a temporary directory and are diff --git a/test/integration/application/Runtime.entityCapture.concurrent.test.ts b/test/integration/application/Runtime.entityCapture.concurrent.test.ts new file mode 100644 index 000000000..be128fd86 --- /dev/null +++ b/test/integration/application/Runtime.entityCapture.concurrent.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { Runtime } from '../../../index.ts'; +import Intent from '../../../src/domain/api/Intent.ts'; +import type Lane from '../../../src/domain/api/Lane.ts'; +import type WarpState from '../../../src/domain/services/state/WarpState.ts'; +import type { PropValue } from '../../../src/domain/types/PropValue.ts'; +import { createTestRepo } from '../api/helpers/setup.ts'; + +const LANE = 'think'; +const SUBJECT = 'entry:same'; + +/** + * What `addEntity`'s uniqueness guard actually promises on the lane path. + * + * It refuses an id it can *see* — one this patch already added, or one alive in + * the materialized basis the builder was opened against. On the `Runtime` → + * `Lane.write` path it can see neither, so it never fires: + * + * - `Runtime` exposes `lane`, `fork`, `strand`, `settle` and `close`, and + * `Lane` exposes `write`. Nothing there materializes, so the builder's basis + * is always null and the "alive in the basis" arm is unreachable. + * Materializing is a reader concern, and readers do not write. + * - `Lane.write` lowers one intent per patch, and `addEntity` validates before + * it adds the node, so the "added earlier in this patch" arm is unreachable + * too. + * + * The three tests below walk that from the tightest case outwards: one writer + * on one lane, then two writers holding a shared frontier, then a writer that + * opens only after the other's patch is durable. All three are admitted. The + * guard is reachable only for a direct `PatchBuilder` opened against a + * materialized state — see `test/unit/domain/services/PatchBuilder.entity.test.ts`. + * + * Collision-resistant ids are therefore the application's job, not the + * substrate's. These tests exist so the documented promise stays the one the + * substrate actually keeps. + */ +describe('entity capture uniqueness on the lane write path', () => { + let repository: Awaited>; + + beforeEach(async () => { + repository = await createTestRepo('entity-capture-concurrent'); + }); + + afterEach(async () => { + await repository.cleanup(); + }); + + it('cannot refuse a re-creation even on one lane held by one writer', async () => { + // The tightest case there is: nothing is concurrent, nothing is remote, and + // the first patch is already in this lane's own history. The guard still + // has no basis to see it in. + const runtime = await Runtime.open({ at: repository.tempDir, writer: 'writer-a' }); + try { + const lane = await runtime.lane(LANE); + await write(lane, 'first'); + await expect(write(lane, 'second')).resolves.toBeUndefined(); + } finally { + await runtime.close(); + } + + expect(await creationCount()).toBe(2); + }); + + it('admits both writers holding a shared frontier and merges them', async () => { + // Both runtimes are open before either writes, so neither could observe the + // other even if it had a basis. This is the genuine frontier case: the + // sequential test below cannot distinguish it from "never materialized". + const a = await Runtime.open({ at: repository.tempDir, writer: 'writer-a' }); + const b = await Runtime.open({ at: repository.tempDir, writer: 'writer-b' }); + try { + const laneA = await a.lane(LANE); + const laneB = await b.lane(LANE); + await write(laneA, 'from a'); + await expect(write(laneB, 'from b')).resolves.toBeUndefined(); + } finally { + await b.close(); + await a.close(); + } + + const slice = await sliceOfSubject(); + + // Both creations are real facts. Neither was silently dropped, and the + // entity's cone is no longer a singleton. + expect(slice.patchCount).toBe(2); + expect(slice.state.nodeAlive.contains(SUBJECT)).toBe(true); + expect([...slice.state.nodeAlive.getDots(SUBJECT)]).toHaveLength(2); + + // The merged property is one of the two, decided by the register's + // conflict rule — not a blend, and not an error. + expect(['from a', 'from b']).toContain(propertiesOf(slice.state, SUBJECT)['text']); + }); + + it('admits a writer that opens only after the first creation is durable', async () => { + await captureThroughOwnRuntime('writer-a', 'from a'); + + // writer-b opens after writer-a's patch is committed and its runtime + // closed, so the id it is about to create already exists on disk. It is + // still admitted, because opening a lane does not materialize anything. + await expect(captureThroughOwnRuntime('writer-b', 'from b')).resolves.toBeUndefined(); + + expect(await creationCount()).toBe(2); + }); + + async function write(lane: Lane, text: string): Promise { + await lane.write( + Intent.addEntity({ + subject: SUBJECT, + properties: { kind: 'capture', text }, + }) + ); + } + + async function captureThroughOwnRuntime(writer: string, text: string): Promise { + const runtime = await Runtime.open({ at: repository.tempDir, writer }); + try { + await write(await runtime.lane(LANE), text); + } finally { + await runtime.close(); + } + } + + async function sliceOfSubject(): Promise<{ + patchCount: number; + state: WarpState; + }> { + const graph = await repository.openGraph(LANE, 'reader'); + await graph.materialize(); + return graph.materializeSlice(SUBJECT); + } + + async function creationCount(): Promise { + const slice = await sliceOfSubject(); + return slice.patchCount; + } +}); + +function propertiesOf(state: WarpState, nodeId: string): Record { + const properties: Record = {}; + for (const entry of state.nodeProperties()) { + if (entry.nodeId === nodeId) { + properties[entry.key] = entry.register.value; + } + } + return properties; +} diff --git a/test/integration/application/Runtime.entityCapture.integration.test.ts b/test/integration/application/Runtime.entityCapture.integration.test.ts new file mode 100644 index 000000000..a3abcf269 --- /dev/null +++ b/test/integration/application/Runtime.entityCapture.integration.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { Runtime } from '../../../index.ts'; +import Intent from '../../../src/domain/api/Intent.ts'; +import { intentFromPatch } from '../../../src/domain/api/IntentRuntime.ts'; +import type WarpState from '../../../src/domain/services/state/WarpState.ts'; +import type { PropValue } from '../../../src/domain/types/PropValue.ts'; +import { createTestRepo } from '../api/helpers/setup.ts'; + +const LANE = 'think'; +const WRITER = 'claude'; + +const MEMORY = { + ambientGitBranch: 'feature/git-warp-v19-cutover', + ambientGitRemote: 'git@github.com:flyingrobots/think.git', + createdAt: '2026-08-01T05:13:00.000Z', + kind: 'capture', + schemaVersion: 1, + sortKey: '1785597386985-c538d1bd', + text: 'probe write two', +}; + +describe('Runtime entity capture provenance', () => { + let repository: Awaited>; + + beforeEach(async () => { + repository = await createTestRepo('runtime-entity-capture'); + }); + + afterEach(async () => { + await repository.cleanup(); + }); + + it('survives a reopen and answers provenance with exactly its own creation evidence', async () => { + await captureEntities(['entry:1', 'entry:2', 'entry:3']); + + // Reopened from disk: nothing below reuses the writing Runtime's memory. + const graph = await repository.openGraph(LANE, WRITER); + await graph.materialize(); + + const cone = await graph.patchesFor('entry:2'); + expect(cone).toHaveLength(1); + + const [creationSha = ''] = cone; + const persisted = await graph.loadPatchBySha(creationSha); + expect(persisted.reads).toBeUndefined(); + expect(persisted.writes).toEqual(['entry:2']); + expect(persisted.ops).toHaveLength(1 + Object.keys(MEMORY).length); + + // The persisted patch still reads back as the entity Intent that wrote it. + expect(intentFromPatch(persisted).descriptor).toEqual({ + kind: 'entity.add', + subject: 'entry:2', + properties: { ...MEMORY }, + }); + + // The slice replays the whole entity from that one patch alone. + const slice = await graph.materializeSlice('entry:2'); + expect(slice.patchCount).toBe(1); + expect(slice.state.nodeAlive.contains('entry:2')).toBe(true); + expect(propertiesOf(slice.state, 'entry:2')).toEqual({ ...MEMORY }); + + // Membership is not causation: the siblings are absent from this cone. + expect(slice.state.nodeAlive.contains('entry:1')).toBe(false); + expect(slice.state.nodeAlive.contains('entry:3')).toBe(false); + }); + + it('gives every capture a singleton cone, however many precede it', async () => { + const subjects = ['entry:1', 'entry:2', 'entry:3', 'entry:4', 'entry:5']; + await captureEntities(subjects); + + const graph = await repository.openGraph(LANE, WRITER); + await graph.materialize(); + + const cones = new Map(); + for (const subject of subjects) { + cones.set(subject, await graph.patchesFor(subject)); + } + + // Cost tracks the query, not the history: the last capture costs what the + // first one costs. + expect([...cones.values()].map((cone) => cone.length)).toEqual([1, 1, 1, 1, 1]); + + // Cone is a strict subset of the universe: five captures, five distinct + // patches, and no cone names a patch belonging to another entity. + const shas = [...cones.values()].flat(); + expect(new Set(shas).size).toBe(subjects.length); + + for (const subject of subjects) { + const slice = await graph.materializeSlice(subject); + expect(slice.patchCount).toBe(1); + expect(propertiesOf(slice.state, subject)).toEqual({ ...MEMORY }); + } + }); + + async function captureEntities(subjects: readonly string[]): Promise { + const runtime = await Runtime.open({ at: repository.tempDir, writer: WRITER }); + try { + const lane = await runtime.lane(LANE); + for (const subject of subjects) { + await lane.write(Intent.addEntity({ subject, properties: { ...MEMORY } })); + } + } finally { + await runtime.close(); + } + } +}); + +function propertiesOf(state: WarpState, nodeId: string): Record { + const properties: Record = {}; + for (const entry of state.nodeProperties()) { + if (entry.nodeId === nodeId) { + properties[entry.key] = entry.register.value; + } + } + return properties; +} diff --git a/test/integration/application/Runtime.entityOccurrence.integration.test.ts b/test/integration/application/Runtime.entityOccurrence.integration.test.ts new file mode 100644 index 000000000..f2df87c57 --- /dev/null +++ b/test/integration/application/Runtime.entityOccurrence.integration.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { Runtime, type EntityOccurrence } from '../../../index.ts'; +import { intent } from '../../../advanced.ts'; +import { receiptEnvelope } from '../../../bin/presenters/V19ReadingReceipt.ts'; +import { createTestRepo } from '../api/helpers/setup.ts'; + +const LANE = 'think'; +const CAPTURED_AT = '2026-08-03T20:00:00.000Z'; + +describe('Runtime entity occurrence receipts', () => { + let repository: Awaited>; + + beforeEach(async () => { + repository = await createTestRepo('runtime-entity-occurrence'); + }); + + afterEach(async () => { + await repository.cleanup(); + }); + + it('allocates distinct subjects and occurrence ids despite identical application time', async () => { + const runtime = await Runtime.open({ at: repository.tempDir, writer: 'writer-a' }); + try { + const lane = await runtime.lane(LANE); + const firstReceipt = await lane.write(capture()); + const first = requireOccurrence(firstReceipt); + const second = requireOccurrence(await lane.write(capture())); + + expect(first.subject).toMatch(/^entry:[0-9a-f]+$/); + expect(second.subject).toMatch(/^entry:[0-9a-f]+$/); + expect(second.subject).not.toBe(first.subject); + expect(second.id).not.toBe(first.id); + expect(second.relationTo(first)).toBe('after'); + expect(first.relationTo(second)).toBe('before'); + expect(second.compare(first)).toBeGreaterThan(0); + expect(receiptEnvelope(firstReceipt)).toMatchObject({ + operation: 'write', + occurrence: { id: first.id, subject: first.subject }, + }); + } finally { + await runtime.close(); + } + }); + + it('keeps concurrent occurrences incomparable but deterministically ordered', async () => { + const a = await Runtime.open({ at: repository.tempDir, writer: 'writer-a' }); + const b = await Runtime.open({ at: repository.tempDir, writer: 'writer-b' }); + try { + const laneA = await a.lane(LANE); + const laneB = await b.lane(LANE); + const [left, right] = await Promise.all([laneA.write(capture()), laneB.write(capture())]); + const occurrenceA = requireOccurrence(left); + const occurrenceB = requireOccurrence(right); + + expect(occurrenceA.relationTo(occurrenceB)).toBe('concurrent'); + expect(occurrenceB.relationTo(occurrenceA)).toBe('concurrent'); + expect(Math.sign(occurrenceA.compare(occurrenceB))).toBe( + -Math.sign(occurrenceB.compare(occurrenceA)) + ); + expect(occurrenceA.compare(occurrenceB)).not.toBe(0); + } finally { + await b.close(); + await a.close(); + } + }); + + it('scopes colliding writer dots to their independent worldlines', async () => { + const runtime = await Runtime.open({ at: repository.tempDir, writer: 'writer-a' }); + try { + const leftLane = await runtime.lane('left'); + const rightLane = await runtime.lane('right'); + const left = requireOccurrence(await leftLane.write(capture())); + const right = requireOccurrence(await rightLane.write(capture())); + + expect(left.subject).toBe(right.subject); + expect(left.id).not.toBe(right.id); + expect(left.relationTo(right)).toBe('concurrent'); + expect(right.relationTo(left)).toBe('concurrent'); + expect(left.compare(right)).not.toBe(0); + } finally { + await runtime.close(); + } + }); + + it('returns a new occurrence for every supplied-subject admission', async () => { + const runtime = await Runtime.open({ at: repository.tempDir, writer: 'writer-a' }); + try { + const lane = await runtime.lane(LANE); + const supplied = intent.entity.add({ + subject: 'entry:semantic-subject', + properties: { kind: 'capture', capturedAt: CAPTURED_AT }, + }); + const first = requireOccurrence(await lane.write(supplied)); + const second = requireOccurrence(await lane.write(supplied)); + + expect(first.subject).toBe('entry:semantic-subject'); + expect(second.subject).toBe(first.subject); + expect(second.id).not.toBe(first.id); + expect(second.relationTo(first)).toBe('after'); + } finally { + await runtime.close(); + } + }); + + it('settles the auto-allocated strand subject without reminting it', async () => { + let runtime: Runtime | null = await Runtime.open({ + at: repository.tempDir, + writer: 'writer-a', + }); + try { + const parent = await runtime.lane(LANE); + const strand = await runtime.fork(parent, { name: 'candidate' }); + const occurrence = requireOccurrence(await strand.write(capture())); + + const preview = await runtime.previewSettlement({ source: strand, target: parent }); + await expect(runtime.settle(preview.plan)).resolves.toMatchObject({ + outcome: { kind: 'derived' }, + }); + await runtime.close(); + runtime = null; + + const graph = await repository.openGraph(LANE, 'writer-a'); + const state = await graph.materialize(); + expect(state.nodeAlive.contains(occurrence.subject)).toBe(true); + } finally { + if (runtime !== null) { + await runtime.close(); + } + } + }); +}); + +function capture() { + return intent.entity.addAuto({ + namespace: 'entry', + properties: { kind: 'capture', capturedAt: CAPTURED_AT }, + }); +} + +function requireOccurrence(receipt: { + readonly occurrence: EntityOccurrence | undefined; +}): EntityOccurrence { + if (receipt.occurrence === undefined) { + throw new Error('entity write receipt must carry an occurrence'); + } + return receipt.occurrence; +} diff --git a/test/type-check/v19-subpaths.ts b/test/type-check/v19-subpaths.ts index fad02d9fe..4edd1381b 100644 --- a/test/type-check/v19-subpaths.ts +++ b/test/type-check/v19-subpaths.ts @@ -6,6 +6,8 @@ */ import { + type EntityCausalRelation, + type EntityOccurrence, type Intent, type Lane, type Observer, @@ -42,6 +44,10 @@ const advancedIntent: Intent = intent.property.set({ key: 'role', value: 'admin', }); +const allocatedEntityIntent: Intent = intent.entity.addAuto({ + namespace: 'entry', + properties: { kind: 'capture', capturedAt: '2026-08-03T20:00:00.000Z' }, +}); const advancedObserver: Observer = createObserver( 'users.role-of', reading.property({ subject: 'user:alice', key: 'role' }), @@ -50,9 +56,14 @@ const advancedObserver: Observer = createObserver( throw new TypeError('users.role-of expected a string'); } return value; - }, + } ); declare const receipt: WriteReceipt; +declare const otherOccurrence: EntityOccurrence; +const occurrenceRelation: EntityCausalRelation | undefined = + receipt.occurrence?.relationTo(otherOccurrence); +const occurrenceOrder: number | undefined = receipt.occurrence?.compare(otherOccurrence); +const occurrenceSubject: string | undefined = receipt.occurrence?.subject; const inspection: ReceiptInspection = inspectReceipt(receipt); const inspectedLane: string = inspection.lane; const substrate: ReceiptSubstrateInspection = inspection.substrate; @@ -77,9 +88,13 @@ await harness.close(); void optic; void witness; void advancedIntent; +void allocatedEntityIntent; void advancedObserver; void inspection; void inspectedLane; void substrate; +void occurrenceRelation; +void occurrenceOrder; +void occurrenceSubject; void chart; void isRuntimeChart; diff --git a/test/unit/cli/v19-entity-intent.test.ts b/test/unit/cli/v19-entity-intent.test.ts new file mode 100644 index 000000000..35da0d0d9 --- /dev/null +++ b/test/unit/cli/v19-entity-intent.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import { intentFromText, intentFromValue } from '../../../bin/cli/v19/V19DomainInput.ts'; + +describe('v19 CLI entity Intent input', () => { + it('accepts an entity capture with its complete payload', () => { + expect( + intentFromValue({ + kind: 'entity.add', + subject: 'entry:1', + properties: { kind: 'capture', text: 'a fact' }, + }).descriptor + ).toEqual({ + kind: 'entity.add', + subject: 'entry:1', + properties: { kind: 'capture', text: 'a fact' }, + }); + }); + + it('accepts the same entity capture as JSON text', () => { + expect( + intentFromText( + JSON.stringify({ + kind: 'entity.add', + subject: 'entry:1', + properties: { count: 1 }, + }) + ).kind + ).toBe('entity.add'); + }); + + it('accepts substrate allocation in an application namespace', () => { + expect( + intentFromValue({ + kind: 'entity.add', + namespace: 'entry', + properties: { kind: 'capture', capturedAt: '2026-08-03T20:00:00.000Z' }, + }).descriptor + ).toEqual({ + kind: 'entity.add', + namespace: 'entry', + properties: { capturedAt: '2026-08-03T20:00:00.000Z', kind: 'capture' }, + }); + }); + + it('rejects an entity capture with no payload', () => { + expect(() => + intentFromValue({ + kind: 'entity.add', + subject: 'entry:1', + properties: {}, + }) + ).toThrow(); + }); + + it('rejects an entity capture with no subject', () => { + expect(() => + intentFromValue({ + kind: 'entity.add', + subject: '', + properties: { kind: 'capture' }, + }) + ).toThrow(); + }); + + it('rejects an entity capture with no identity', () => { + expect(() => + intentFromValue({ + kind: 'entity.add', + properties: { kind: 'capture' }, + }) + ).toThrow(); + }); + + it('rejects an entity capture with both supplied and allocated identity', () => { + expect(() => + intentFromValue({ + kind: 'entity.add', + subject: 'entry:1', + namespace: 'entry', + properties: { kind: 'capture' }, + }) + ).toThrow(); + }); + + it('rejects unknown fields on an entity capture', () => { + expect(() => + intentFromValue({ + kind: 'entity.add', + subject: 'entry:1', + properties: { kind: 'capture' }, + extra: 'nope', + }) + ).toThrow(); + }); +}); diff --git a/test/unit/domain/EntityOccurrence.test.ts b/test/unit/domain/EntityOccurrence.test.ts new file mode 100644 index 000000000..b0b8eec48 --- /dev/null +++ b/test/unit/domain/EntityOccurrence.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest'; + +import EntityOccurrence from '../../../src/domain/api/EntityOccurrence.ts'; +import { createEntityOccurrence } from '../../../src/domain/api/EntityOccurrenceRuntime.ts'; +import { intent } from '../../../src/domain/api/IntentBuilders.ts'; +import { Dot } from '../../../src/domain/crdt/Dot.ts'; +import { EventId } from '../../../src/domain/utils/EventId.ts'; + +const EVIDENCE = Object.freeze({ + basis: Object.freeze({ id: 'evidence:entity-occurrence' }), + support: Object.freeze([]), +}); + +describe('EntityOccurrence', () => { + it('keeps occurrence identity, subject identity, and application time separate', () => { + const first = occurrence({ + context: { writer: 1 }, + counter: 1, + lamport: 1, + patchSha: 'aaaa', + subject: 'entry:semantic', + }); + const second = occurrence({ + context: { writer: 2 }, + counter: 2, + lamport: 2, + patchSha: 'bbbb', + subject: 'entry:semantic', + }); + + expect(first.subject).toBe(second.subject); + expect(first.id).not.toBe(second.id); + expect(first.relationTo(first)).toBe('same'); + expect(first.relationTo(second)).toBe('before'); + expect(second.relationTo(first)).toBe('after'); + expect(first.compare(second)).toBeLessThan(0); + expect(second.compare(first)).toBeGreaterThan(0); + expect(Object.isFrozen(first)).toBe(true); + }); + + it('keeps concurrent vectors incomparable while EventId supplies a stable order', () => { + const left = occurrence({ + context: { alice: 1 }, + counter: 1, + lamport: 1, + patchSha: 'aaaa', + subject: 'entry:left', + writer: 'alice', + }); + const right = occurrence({ + context: { bob: 1 }, + counter: 1, + lamport: 1, + patchSha: 'bbbb', + subject: 'entry:right', + writer: 'bob', + }); + + expect(left.relationTo(right)).toBe('concurrent'); + expect(right.relationTo(left)).toBe('concurrent'); + expect(left.compare(right)).toBeLessThan(0); + expect(right.compare(left)).toBeGreaterThan(0); + }); + + it('rejects impossible causal cycles between distinct dots', () => { + const left = occurrence({ + context: { alice: 1, bob: 1 }, + counter: 1, + lamport: 1, + patchSha: 'aaaa', + subject: 'entry:left', + writer: 'alice', + }); + const right = occurrence({ + context: { alice: 1, bob: 1 }, + counter: 1, + lamport: 1, + patchSha: 'bbbb', + subject: 'entry:right', + writer: 'bob', + }); + + expect(() => left.relationTo(right)).toThrowError( + expect.objectContaining({ + code: 'E_ENTITY_OCCURRENCE_CAUSAL_CYCLE', + }) + ); + }); + + it('admits only substrate-issued occurrences into coordinate operations', () => { + const issued = occurrence({ + context: { writer: 1 }, + counter: 1, + lamport: 1, + patchSha: 'aaaa', + subject: 'entry:issued', + }); + const forged = Object.create(EntityOccurrence.prototype); + + expect(() => issued.compare(forged)).toThrowError( + expect.objectContaining({ + code: 'E_ENTITY_OCCURRENCE_UNAVAILABLE', + }) + ); + // @ts-expect-error Exercise the JavaScript boundary. + expect(() => issued.compare(null)).toThrowError( + expect.objectContaining({ + code: 'E_ENTITY_OCCURRENCE_TYPE', + }) + ); + }); + + it('validates the substrate construction boundary', () => { + expect(() => + createEntityOccurrence({ + context: {}, + // @ts-expect-error Exercise the JavaScript boundary. + dot: {}, + ...receiptBinding('entry:1'), + eventId: new EventId(1, 'writer', 'aaaa', 0), + subject: 'entry:1', + worldline: 'events', + }) + ).toThrowError(expect.objectContaining({ code: 'E_ENTITY_OCCURRENCE_DOT' })); + expect(() => + createEntityOccurrence({ + context: {}, + dot: Dot.create('writer', 1), + ...receiptBinding('entry:1'), + // @ts-expect-error Exercise the JavaScript boundary. + eventId: {}, + subject: 'entry:1', + worldline: 'events', + }) + ).toThrowError(expect.objectContaining({ code: 'E_ENTITY_OCCURRENCE_EVENT' })); + expect(() => + createEntityOccurrence({ + context: {}, + dot: Dot.create('writer', 1), + ...receiptBinding('entry:1'), + eventId: new EventId(1, 'writer', 'aaaa', 0), + receiptWriter: '', + subject: 'entry:1', + worldline: 'events', + }) + ).toThrowError(expect.objectContaining({ code: 'E_VALIDATION' })); + expect(() => + createEntityOccurrence({ + context: {}, + dot: Dot.create('writer', 1), + ...receiptBinding('entry:1'), + eventId: new EventId(1, 'writer', 'aaaa', 0), + subject: 'entry:1', + worldline: '', + }) + ).toThrowError(expect.objectContaining({ code: 'E_VALIDATION' })); + expect(() => + createEntityOccurrence({ + context: {}, + dot: Dot.create('writer', 1), + ...receiptBinding('entry:other'), + eventId: new EventId(1, 'writer', 'aaaa', 0), + subject: 'entry:1', + worldline: 'events', + }) + ).toThrowError(expect.objectContaining({ code: 'E_ENTITY_OCCURRENCE_SUBJECT' })); + expect(() => + createEntityOccurrence({ + context: {}, + dot: Dot.create('writer', 1), + ...receiptBinding('entry:1'), + eventId: new EventId(1, 'other-writer', 'aaaa', 0), + subject: 'entry:1', + worldline: 'events', + }) + ).toThrowError(expect.objectContaining({ code: 'E_ENTITY_OCCURRENCE_WRITER' })); + }); +}); + +function occurrence(fields: { + readonly context: Readonly>; + readonly counter: number; + readonly lamport: number; + readonly patchSha: string; + readonly subject: string; + readonly writer?: string; + readonly worldline?: string; +}): EntityOccurrence { + const writer = fields.writer ?? 'writer'; + return createEntityOccurrence({ + context: fields.context, + dot: Dot.create(writer, fields.counter), + ...receiptBinding(fields.subject, writer), + eventId: new EventId(fields.lamport, writer, fields.patchSha, 0), + subject: fields.subject, + worldline: fields.worldline ?? 'events', + }); +} + +function receiptBinding(subject: string, receiptWriter = 'writer') { + return { + evidence: EVIDENCE, + intent: intent.entity.add({ subject, properties: { kind: 'capture' } }), + receiptWriter, + }; +} diff --git a/test/unit/domain/EvidenceRuntime.test.ts b/test/unit/domain/EvidenceRuntime.test.ts new file mode 100644 index 000000000..096028c7a --- /dev/null +++ b/test/unit/domain/EvidenceRuntime.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import { freezeEvidence } from '../../../src/domain/api/EvidenceRuntime.ts'; +import RetentionEvidence from '../../../src/domain/api/RetentionEvidence.ts'; +import Tick from '../../../src/domain/api/Tick.ts'; + +describe('freezeEvidence', () => { + it('canonicalizes raw evidence once and reuses the canonical identity', () => { + const raw = { + basis: { id: 'evidence:basis' }, + support: [{ id: 'evidence:support' }], + }; + + const canonical = freezeEvidence(raw, 'test.evidence'); + + expect(canonical).not.toBe(raw); + expect(canonical).toEqual(raw); + expect(Object.isFrozen(canonical)).toBe(true); + expect(Object.isFrozen(canonical.basis)).toBe(true); + expect(Object.isFrozen(canonical.support)).toBe(true); + expect(Object.isFrozen(canonical.support[0])).toBe(true); + expect(freezeEvidence(canonical, 'test.evidence')).toBe(canonical); + }); + + it('recognizes canonical evidence without process-local membership', () => { + const canonical = Object.freeze({ + basis: Object.freeze({ id: 'evidence:basis' }), + support: Object.freeze([Object.freeze({ id: 'evidence:support' })]), + }); + + expect(freezeEvidence(canonical, 'test.evidence')).toBe(canonical); + }); + + it('retains a validated Tick while canonicalizing retention evidence', () => { + const tick = new Tick({ id: 'tick:1', timeline: 'events' }); + const retention = new RetentionEvidence({ + witness: { id: 'evidence:retention' }, + policy: 'pinned', + reachability: 'anchored', + rootKind: 'publication', + }); + + const canonical = freezeEvidence( + { + basis: { id: 'evidence:basis' }, + support: [], + retention: [retention], + tick, + }, + 'test.evidence' + ); + + expect(canonical.tick).toBe(tick); + expect(canonical.retention).toHaveLength(1); + expect(canonical.retention?.[0]).not.toBe(retention); + expect(canonical.retention?.[0]).toEqual(retention); + expect(Object.isFrozen(canonical.retention)).toBe(true); + }); + + it('rejects malformed retention evidence before canonicalization', () => { + expect(() => + freezeEvidence( + { + basis: { id: 'evidence:basis' }, + support: [], + // @ts-expect-error Exercise the JavaScript boundary with a scalar. + retention: 'persistent', + }, + 'test.evidence' + ) + ).toThrowError(expect.objectContaining({ code: 'E_RECEIPT_EVIDENCE' })); + expect(() => + freezeEvidence( + { + basis: { id: 'evidence:basis' }, + support: [], + // @ts-expect-error Exercise the JavaScript boundary with null. + retention: [null], + }, + 'test.evidence' + ) + ).toThrowError(expect.objectContaining({ code: 'E_RECEIPT_EVIDENCE' })); + }); + + it('rejects forged canonical retention evidence with invalid fields', () => { + const forgedRetention = Object.freeze( + Object.assign(Object.create(RetentionEvidence.prototype), { + witness: Object.freeze({ id: 'evidence:retention' }), + policy: 'expired', + reachability: 'anchored', + rootKind: 'publication', + }) + ); + const forgedEvidence = Object.freeze({ + basis: Object.freeze({ id: 'evidence:basis' }), + support: Object.freeze([]), + retention: Object.freeze([forgedRetention]), + }); + + expect(() => freezeEvidence(forgedEvidence, 'test.evidence')).toThrowError( + expect.objectContaining({ code: 'E_RECEIPT_EVIDENCE' }) + ); + }); +}); diff --git a/test/unit/domain/Intent.entity.test.ts b/test/unit/domain/Intent.entity.test.ts new file mode 100644 index 000000000..c4106f679 --- /dev/null +++ b/test/unit/domain/Intent.entity.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; + +import Intent from '../../../src/domain/api/Intent.ts'; +import { intent } from '../../../src/domain/api/IntentBuilders.ts'; +import type { PropValue } from '../../../src/domain/types/PropValue.ts'; + +describe('Intent entity descriptors', () => { + it('describes one entity creation with its provided initial payload', () => { + const created = Intent.addEntity({ + subject: 'entry:1', + properties: { kind: 'capture', text: 'a fact' }, + }); + + expect(created.kind).toBe('entity.add'); + expect(created.descriptor).toEqual({ + kind: 'entity.add', + subject: 'entry:1', + properties: { kind: 'capture', text: 'a fact' }, + }); + }); + + it('is reachable through the public intent builders', () => { + expect( + intent.entity.add({ + subject: 'entry:1', + properties: { kind: 'capture' }, + }).kind + ).toBe('entity.add'); + }); + + it('describes substrate allocation without inventing an application subject', () => { + const created = Intent.addEntityAuto({ + namespace: 'entry', + properties: { kind: 'capture', capturedAt: '2026-08-03T20:00:00.000Z' }, + }); + + expect(created.kind).toBe('entity.add'); + expect(created.descriptor).toEqual({ + kind: 'entity.add', + namespace: 'entry', + properties: { capturedAt: '2026-08-03T20:00:00.000Z', kind: 'capture' }, + }); + expect('subject' in created.descriptor).toBe(false); + }); + + it('exposes substrate allocation through a distinct public builder', () => { + expect( + intent.entity.addAuto({ + namespace: 'entry', + properties: { kind: 'capture' }, + }).descriptor + ).toEqual({ + kind: 'entity.add', + namespace: 'entry', + properties: { kind: 'capture' }, + }); + }); + + it('treats explicitly undefined optional identities as absent', () => { + const autoFields = { + subject: undefined, + namespace: 'entry', + properties: { kind: 'capture' }, + }; + const suppliedFields = { + subject: 'entry:1', + namespace: undefined, + properties: { kind: 'capture' }, + }; + + expect(Intent.addEntityAuto(autoFields).descriptor).toMatchObject({ namespace: 'entry' }); + expect(Intent.addEntity(suppliedFields).descriptor).toMatchObject({ subject: 'entry:1' }); + }); + + it('rejects an empty allocation namespace', () => { + expect(() => + Intent.addEntityAuto({ + namespace: '', + properties: { kind: 'capture' }, + }) + ).toThrow(); + }); + + it('copies the payload so the descriptor cannot be mutated after the fact', () => { + const properties = { tags: ['first'] }; + const created = Intent.addEntity({ subject: 'entry:1', properties }); + + properties.tags.push('second'); + const first = created.descriptor; + const second = created.descriptor; + + expect(first).toEqual({ + kind: 'entity.add', + subject: 'entry:1', + properties: { tags: ['first'] }, + }); + expect(first).not.toBe(second); + }); + + it('rejects an entity with no properties', () => { + expect(() => Intent.addEntity({ subject: 'entry:1', properties: {} })).toThrowError( + expect.objectContaining({ code: 'E_INTENT_ENTITY_EMPTY' }) + ); + }); + + it('rejects a non-record payload at the public JavaScript boundary', () => { + expect(() => + Intent.addEntity({ + subject: 'entry:1', + // @ts-expect-error Exercise the JavaScript boundary. + properties: 'capture', + }) + ).toThrowError(expect.objectContaining({ code: 'E_INTENT_ENTITY_PAYLOAD' })); + }); + + it('rejects a missing subject', () => { + expect(() => Intent.addEntity({ subject: '', properties: { kind: 'capture' } })).toThrow(); + }); + + it('rejects payload values that are not property-compatible', () => { + expect(() => + Intent.addEntity({ + subject: 'entry:1', + // @ts-expect-error Exercise the JavaScript boundary. + properties: { broken: new InvalidPropertyCarrier() }, + }) + ).toThrowError(expect.objectContaining({ code: 'E_INTENT_VALUE' })); + }); + + it('rejects an empty property key', () => { + expect(() => + Intent.addEntity({ + subject: 'entry:1', + properties: { '': 'capture' }, + }) + ).toThrow(); + }); + + it('describes payloads that differ only in key order identically', () => { + expect( + Intent.addEntity({ + subject: 'entry:1', + properties: { kind: 'capture', text: 'hello' }, + }).descriptor + ).toEqual( + Intent.addEntity({ + subject: 'entry:1', + properties: { text: 'hello', kind: 'capture' }, + }).descriptor + ); + }); + + it('orders payload keys canonically rather than by insertion', () => { + const created = Intent.addEntity({ + subject: 'entry:1', + properties: { c: 3, a: 1, b: 2 }, + }); + + expect(Object.keys(entityProperties(created))).toEqual(['a', 'b', 'c']); + }); + + it('keeps a prototype-shaped key as ordinary data', () => { + const created = Intent.addEntity({ + subject: 'entry:1', + properties: { ['__proto__']: 'polluted', kind: 'capture' }, + }); + + const properties = entityProperties(created); + expect(Object.hasOwn(properties, '__proto__')).toBe(true); + expect(properties['__proto__']).toBe('polluted'); + expect({}.constructor).toBe(Object); + expect(Object.getOwnPropertyDescriptor(Object.prototype, 'polluted')).toBeUndefined(); + }); + + it('keeps constructor and prototype keys as ordinary data', () => { + const properties = entityProperties( + Intent.addEntity({ + subject: 'entry:1', + properties: { constructor: 'not-a-function', prototype: 'inert' }, + }) + ); + + expect(properties['constructor']).toBe('not-a-function'); + expect(properties['prototype']).toBe('inert'); + }); +}); + +function entityProperties(created: Intent): Record { + const { descriptor } = created; + if (descriptor.kind !== 'entity.add') { + throw new Error('expected an entity.add descriptor'); + } + return descriptor.properties; +} + +class InvalidPropertyCarrier {} diff --git a/test/unit/domain/IntentRuntime.entity.test.ts b/test/unit/domain/IntentRuntime.entity.test.ts new file mode 100644 index 000000000..192a00e35 --- /dev/null +++ b/test/unit/domain/IntentRuntime.entity.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from 'vitest'; + +import { Dot } from '../../../src/domain/crdt/Dot.ts'; +import { applyIntentToPatch, intentFromPatch } from '../../../src/domain/api/IntentRuntime.ts'; +import Intent from '../../../src/domain/api/Intent.ts'; +import Patch from '../../../src/domain/types/Patch.ts'; +import NodeAdd from '../../../src/domain/types/ops/NodeAdd.ts'; +import NodePropSet from '../../../src/domain/types/ops/NodePropSet.ts'; +import type { PatchOp } from '../../../src/domain/types/ops/unions.ts'; +import { createPatchBuilder } from './services/PatchBuilderTestHarness.ts'; + +describe('IntentRuntime entity capture', () => { + it('lowers one entity Intent into one single-subject patch', () => { + const builder = createPatchBuilder({ graphName: 'think', writerId: 'claude' }); + + applyIntentToPatch( + Intent.addEntity({ + subject: 'entry:1', + properties: { kind: 'capture', text: 'a fact' }, + }), + builder + ); + + expect([...builder.reads]).toEqual([]); + expect([...builder.writes]).toEqual(['entry:1']); + expect(builder.build().ops).toHaveLength(3); + }); + + it('allocates an opaque subject from the NodeAdd dot', () => { + const builder = createPatchBuilder({ graphName: 'think', writerId: 'claude' }); + + applyIntentToPatch( + Intent.addEntityAuto({ + namespace: 'entry', + properties: { kind: 'capture', capturedAt: '2026-08-03T20:00:00.000Z' }, + }), + builder + ); + + const built = builder.build(); + const leading = built.ops[0]; + expect(leading).toBeInstanceOf(NodeAdd); + if (!(leading instanceof NodeAdd)) { + throw new Error('expected allocated entity to begin with NodeAdd'); + } + expect(leading.node).toMatch(/^entry:[0-9a-f]+$/); + expect([...builder.reads]).toEqual([]); + expect([...builder.writes]).toEqual([leading.node]); + expect(built.ops).toHaveLength(3); + }); + + it('recovers an entity Intent from its persisted operations', () => { + expect( + intentFromPatch( + entityPatch('entry:1', [ + new NodeAdd('entry:1', Dot.create('claude', 1)), + new NodePropSet('entry:1', 'kind', 'capture'), + new NodePropSet('entry:1', 'text', 'a fact'), + ]) + ).descriptor + ).toEqual({ + kind: 'entity.add', + subject: 'entry:1', + properties: { kind: 'capture', text: 'a fact' }, + }); + }); + + it('round-trips an entity Intent through a real PatchBuilder', () => { + const builder = createPatchBuilder({ graphName: 'think', writerId: 'claude' }); + const original = Intent.addEntity({ + subject: 'entry:1', + properties: { kind: 'capture', text: 'a fact', length: 6 }, + }); + applyIntentToPatch(original, builder); + + expect(intentFromPatch(builder.build()).descriptor).toEqual(original.descriptor); + }); + + it('still recovers a bare NodeAdd as a node Intent', () => { + expect( + intentFromPatch( + patch([new NodeAdd('entry:1', Dot.create('claude', 1))], { writes: ['entry:1'] }) + ).descriptor + ).toEqual({ + kind: 'node.add', + subject: 'entry:1', + }); + }); + + it('rejects a NodeAdd whose payload writes a different node', () => { + expect(() => + intentFromPatch( + patch( + [ + new NodeAdd('entry:1', Dot.create('claude', 1)), + new NodePropSet('entry:2', 'kind', 'capture'), + ], + { writes: ['entry:1', 'entry:2'] } + ) + ) + ).toThrowError( + expect.objectContaining({ + code: 'E_DRAFT_INTENT_HYDRATION', + }) + ); + }); + + it('rejects properties that precede the node they belong to', () => { + expect(() => + intentFromPatch( + entityPatch('entry:1', [ + new NodePropSet('entry:1', 'kind', 'capture'), + new NodeAdd('entry:1', Dot.create('claude', 1)), + ]) + ) + ).toThrowError( + expect.objectContaining({ + code: 'E_DRAFT_INTENT_HYDRATION', + }) + ); + }); + + describe('footprint is evidence, not decoration', () => { + it('refuses to read entity capture into a patch that records a read', () => { + expect(() => + intentFromPatch( + patch( + [ + new NodeAdd('entry:1', Dot.create('claude', 1)), + new NodePropSet('entry:1', 'kind', 'capture'), + ], + { reads: ['entry:1'], writes: ['entry:1'] } + ) + ) + ).toThrowError(expect.objectContaining({ code: 'E_DRAFT_INTENT_HYDRATION' })); + }); + + it('refuses a patch that writes more than the created subject', () => { + expect(() => + intentFromPatch( + patch( + [ + new NodeAdd('entry:1', Dot.create('claude', 1)), + new NodePropSet('entry:1', 'kind', 'capture'), + ], + { writes: ['entry:1', 'entry:2'] } + ) + ) + ).toThrowError(expect.objectContaining({ code: 'E_DRAFT_INTENT_HYDRATION' })); + }); + + it('refuses a patch that records no footprint at all', () => { + expect(() => + intentFromPatch( + patch([ + new NodeAdd('entry:1', Dot.create('claude', 1)), + new NodePropSet('entry:1', 'kind', 'capture'), + ]) + ) + ).toThrowError(expect.objectContaining({ code: 'E_DRAFT_INTENT_HYDRATION' })); + }); + + it('refuses a patch whose single write names another subject', () => { + expect(() => + intentFromPatch( + patch( + [ + new NodeAdd('entry:1', Dot.create('claude', 1)), + new NodePropSet('entry:1', 'kind', 'capture'), + ], + { writes: ['entry:2'] } + ) + ) + ).toThrowError(expect.objectContaining({ code: 'E_DRAFT_INTENT_HYDRATION' })); + }); + }); + + describe('hostile persisted payloads', () => { + it('refuses a payload that sets the same key twice', () => { + expect(() => + intentFromPatch( + entityPatch('entry:1', [ + new NodeAdd('entry:1', Dot.create('claude', 1)), + new NodePropSet('entry:1', 'kind', 'capture'), + new NodePropSet('entry:1', 'kind', 'annotation'), + ]) + ) + ).toThrowError( + expect.objectContaining({ + code: 'E_DRAFT_INTENT_HYDRATION', + }) + ); + }); + + it('treats a prototype-shaped key as ordinary data', () => { + const recovered = intentFromPatch( + entityPatch('entry:1', [ + new NodeAdd('entry:1', Dot.create('claude', 1)), + new NodePropSet('entry:1', '__proto__', 'polluted'), + ]) + ).descriptor; + + expect(recovered).toEqual( + expect.objectContaining({ + kind: 'entity.add', + subject: 'entry:1', + }) + ); + if (recovered.kind !== 'entity.add') { + throw new Error('expected an entity.add descriptor'); + } + expect(Object.hasOwn(recovered.properties, '__proto__')).toBe(true); + expect({}.constructor).toBe(Object); + expect(Object.getOwnPropertyDescriptor(Object.prototype, 'polluted')).toBeUndefined(); + }); + }); + + describe('canonical property order', () => { + it('lowers payloads that differ only in construction order identically', () => { + expect(opSignature({ kind: 'capture', text: 'hello' })).toEqual( + opSignature({ text: 'hello', kind: 'capture' }) + ); + }); + + it('produces byte-identical patch operations regardless of key order', () => { + expect(JSON.stringify(opSignature({ b: 2, a: 1, c: 3 }))).toBe( + JSON.stringify(opSignature({ c: 3, a: 1, b: 2 })) + ); + }); + }); +}); + +function opSignature(properties: Record) { + const builder = createPatchBuilder({ graphName: 'think', writerId: 'claude' }); + applyIntentToPatch(Intent.addEntity({ subject: 'entry:1', properties }), builder); + return builder.build().ops.map((op) => ({ ...op })); +} + +function entityPatch(subject: string, ops: PatchOp[]): Patch { + return patch(ops, { writes: [subject] }); +} + +function patch(ops: PatchOp[], footprint: { reads?: string[]; writes?: string[] } = {}): Patch { + return new Patch({ + writer: 'claude', + lamport: 1, + context: {}, + ops, + reads: footprint.reads, + writes: footprint.writes, + }); +} diff --git a/test/unit/domain/ReceiptOutcome.test.ts b/test/unit/domain/ReceiptOutcome.test.ts index fa6525e27..8155d35d5 100644 --- a/test/unit/domain/ReceiptOutcome.test.ts +++ b/test/unit/domain/ReceiptOutcome.test.ts @@ -1,20 +1,34 @@ import { describe, expect, it } from 'vitest'; +import AdmissionEvaluation from '../../../src/domain/admission/AdmissionEvaluation.ts'; +import ConflictAdmission from '../../../src/domain/admission/ConflictAdmission.ts'; +import ConflictWitness from '../../../src/domain/admission/ConflictWitness.ts'; import DraftTimeline from '../../../src/domain/api/DraftTimeline.ts'; import { projectAdmissionOutcome } from '../../../src/domain/api/AdmissionOutcomeRuntime.ts'; +import EntityOccurrence from '../../../src/domain/api/EntityOccurrence.ts'; +import { + createEntityOccurrence, + requireIssuedEntityOccurrence, +} from '../../../src/domain/api/EntityOccurrenceRuntime.ts'; import { intent } from '../../../src/domain/api/IntentBuilders.ts'; +import { freezeEvidence } from '../../../src/domain/api/EvidenceRuntime.ts'; import JoinReceipt from '../../../src/domain/api/JoinReceipt.ts'; import { READ_JOIN_RECEIPT_OUTCOMES } from '../../../src/domain/api/ReceiptOutcome.ts'; import WriteReceipt from '../../../src/domain/api/WriteReceipt.ts'; +import { Dot } from '../../../src/domain/crdt/Dot.ts'; +import { EventId } from '../../../src/domain/utils/EventId.ts'; import { testDerivedIntentAdmissionReceipt, testObstructedIntentAdmissionReceipt, } from '../../helpers/intentAdmission.ts'; -const EVIDENCE = Object.freeze({ - basis: Object.freeze({ id: 'evidence:basis' }), - support: Object.freeze([]), -}); +const EVIDENCE = freezeEvidence( + { + basis: { id: 'evidence:basis' }, + support: [], + }, + 'test.evidence' +); describe('receipt outcomes', () => { it('quarantines the transitional read/join outcome axis to five values', () => { @@ -113,6 +127,181 @@ describe('receipt outcomes', () => { expect(receipt.evidence).toEqual(EVIDENCE); }); + it('requires every admitted entity receipt to carry an occurrence', () => { + const outcome = projectAdmissionOutcome( + testDerivedIntentAdmissionReceipt('manual-entity').outcome, + EVIDENCE.basis + ); + + expect( + () => + new WriteReceipt({ + lane: 'events', + writer: 'agent-1', + intent: intent.entity.add({ subject: 'entry:1', properties: { kind: 'capture' } }), + outcome, + evidence: EVIDENCE, + }) + ).toThrowError(expect.objectContaining({ code: 'E_WRITE_RECEIPT_ENTITY_OCCURRENCE' })); + }); + + it('forbids occurrences on entity conflict receipts without requiring one', () => { + const outcome = conflictOutcome(); + const fields = { + lane: 'events', + writer: 'agent-1', + intent: intent.entity.add({ subject: 'entry:1', properties: { kind: 'capture' } }), + outcome, + evidence: EVIDENCE, + }; + + expect(new WriteReceipt(fields).occurrence).toBeUndefined(); + expect(() => new WriteReceipt({ ...fields, occurrence: entityOccurrence() })).toThrowError( + expect.objectContaining({ code: 'E_WRITE_RECEIPT_ENTITY_OCCURRENCE' }) + ); + }); + + it('rejects occurrences on non-entity and obstructed receipts', () => { + const occurrence = entityOccurrence(); + const admitted = projectAdmissionOutcome( + testDerivedIntentAdmissionReceipt('manual-node').outcome, + EVIDENCE.basis + ); + const obstructed = projectAdmissionOutcome( + testObstructedIntentAdmissionReceipt('manual-entity-obstruction').outcome, + EVIDENCE.basis + ); + + expect( + () => + new WriteReceipt({ + lane: 'events', + writer: 'agent-1', + intent: intent.node.add({ subject: 'entry:1' }), + outcome: admitted, + evidence: EVIDENCE, + occurrence, + }) + ).toThrowError(expect.objectContaining({ code: 'E_WRITE_RECEIPT_ENTITY_OCCURRENCE' })); + expect( + () => + new WriteReceipt({ + lane: 'events', + writer: 'agent-1', + intent: intent.entity.add({ subject: 'entry:1', properties: { kind: 'capture' } }), + outcome: obstructed, + evidence: EVIDENCE, + occurrence, + }) + ).toThrowError(expect.objectContaining({ code: 'E_WRITE_RECEIPT_ENTITY_OCCURRENCE' })); + }); + + it('retains the substrate occurrence on an admitted entity receipt', () => { + const entityIntent = intent.entity.add({ subject: 'entry:1', properties: { kind: 'capture' } }); + const occurrence = entityOccurrence(entityIntent); + const receipt = new WriteReceipt({ + lane: 'events', + writer: 'agent-1', + intent: entityIntent, + outcome: projectAdmissionOutcome( + testDerivedIntentAdmissionReceipt('manual-entity').outcome, + EVIDENCE.basis + ), + evidence: EVIDENCE, + occurrence, + }); + + expect(receipt.occurrence).toBe(occurrence); + expect(requireIssuedEntityOccurrence(occurrence, receipt)).toBe(occurrence); + }); + + it('distinguishes the causal coordinate writer from the receipt writer', () => { + const entityIntent = intent.entity.add({ subject: 'entry:1', properties: { kind: 'capture' } }); + const occurrence = entityOccurrence(entityIntent, { + coordinateWriter: 'strand-overlay', + receiptWriter: 'agent-1', + }); + const receipt = new WriteReceipt({ + lane: 'events', + writer: 'agent-1', + intent: entityIntent, + outcome: projectAdmissionOutcome( + testDerivedIntentAdmissionReceipt('strand-entity').outcome, + EVIDENCE.basis + ), + evidence: EVIDENCE, + occurrence, + }); + + expect(receipt.occurrence).toBe(occurrence); + }); + + it('rejects a substrate occurrence transplanted to another entity receipt', () => { + const issuedIntent = intent.entity.add({ subject: 'entry:1', properties: { kind: 'capture' } }); + const occurrence = entityOccurrence(issuedIntent); + const outcome = projectAdmissionOutcome( + testDerivedIntentAdmissionReceipt('transplanted-entity').outcome, + EVIDENCE.basis + ); + const fields = { + lane: 'events', + writer: 'agent-1', + intent: issuedIntent, + outcome, + evidence: EVIDENCE, + occurrence, + }; + const mismatches = [ + { ...fields, lane: 'other' }, + { ...fields, writer: 'agent-2' }, + { + ...fields, + intent: intent.entity.add({ subject: 'entry:other', properties: { kind: 'capture' } }), + }, + { + ...fields, + evidence: Object.freeze({ + basis: Object.freeze({ id: 'evidence:other' }), + support: Object.freeze([]), + }), + }, + ]; + + for (const mismatch of mismatches) { + expect(() => new WriteReceipt(mismatch)).toThrowError( + expect.objectContaining({ + code: 'E_ENTITY_OCCURRENCE_RECEIPT_MISMATCH', + }) + ); + } + }); + + it('rejects an occurrence that was not issued by the substrate', () => { + const occurrence = Object.create(EntityOccurrence.prototype); + Object.defineProperties(occurrence, { + id: { value: 'occurrence:forged' }, + subject: { value: 'entry:forged' }, + }); + + expect( + () => + new WriteReceipt({ + lane: 'events', + writer: 'agent-1', + intent: intent.entity.add({ + subject: occurrence.subject, + properties: { kind: 'capture' }, + }), + outcome: projectAdmissionOutcome( + testDerivedIntentAdmissionReceipt('forged-entity').outcome, + EVIDENCE.basis + ), + evidence: EVIDENCE, + occurrence, + }) + ).toThrowError(expect.objectContaining({ code: 'E_ENTITY_OCCURRENCE_UNAVAILABLE' })); + }); + it('rejects legacy string write outcomes at runtime', () => { expect( () => @@ -120,7 +309,8 @@ describe('receipt outcomes', () => { lane: 'events', writer: 'agent-1', intent: intent.node.add({ subject: 'user:alice' }), - outcome: 'accepted' as never, + // @ts-expect-error Exercise the JavaScript boundary with a legacy value. + outcome: 'accepted', evidence: EVIDENCE, }) ).toThrow('outcome must be an AdmissionOutcome'); @@ -153,8 +343,56 @@ describe('receipt outcomes', () => { writer: 'agent-1', intent: intent.node.add({ subject: 'user:alice' }), outcome, - evidence: evidence as never, + // @ts-expect-error Exercise the JavaScript boundary with malformed evidence. + evidence, }) ).toThrow(message); }); }); + +function entityOccurrence( + entityIntent = intent.entity.add({ subject: 'entry:1', properties: { kind: 'capture' } }), + writers: { + readonly coordinateWriter?: string; + readonly receiptWriter?: string; + } = {} +) { + const coordinateWriter = writers.coordinateWriter ?? 'agent-1'; + return createEntityOccurrence({ + context: { 'agent-1': 1 }, + dot: Dot.create(coordinateWriter, 1), + evidence: EVIDENCE, + eventId: new EventId(1, coordinateWriter, 'aaaa', 0), + intent: entityIntent, + receiptWriter: writers.receiptWriter ?? coordinateWriter, + subject: 'entry:1', + worldline: 'events', + }); +} + +function conflictOutcome() { + return projectAdmissionOutcome( + new ConflictAdmission( + new ConflictWitness({ + evaluation: new AdmissionEvaluation({ + sourceParticipantId: 'agent-1', + destinationRuntimeId: 'runtime:events', + sourceBasisRef: 'frontier:source', + destinationBasisRef: 'frontier:destination', + proposalDigest: 'proposal:entity', + lawDigest: 'law:entity', + profileDigest: 'profile:test', + evaluationCoordinateRef: 'coordinate:destination', + }), + conflictRef: 'conflict:entity', + claimRefs: ['claim:local', 'claim:incoming'], + overlappingFootprintRefs: ['footprint:entity'], + contestedDomain: 'entity', + derivationEvidenceRef: 'evidence:derivation', + overlapEvidenceRef: 'evidence:overlap', + resolutionProcedureRefs: ['procedure:settle'], + }) + ), + EVIDENCE.basis + ); +} diff --git a/test/unit/domain/WriteRuntime.test.ts b/test/unit/domain/WriteRuntime.test.ts index b3ec6e299..7b75e0189 100644 --- a/test/unit/domain/WriteRuntime.test.ts +++ b/test/unit/domain/WriteRuntime.test.ts @@ -11,11 +11,201 @@ import WriterError from '../../../src/domain/errors/WriterError.ts'; import { encodeEdgeKey } from '../../../src/domain/services/KeyCodec.ts'; import type { PatchBuilder } from '../../../src/domain/services/PatchBuilder.ts'; import WarpState from '../../../src/domain/services/state/WarpState.ts'; +import Patch from '../../../src/domain/types/Patch.ts'; +import NodeAdd from '../../../src/domain/types/ops/NodeAdd.ts'; +import NodePropSet from '../../../src/domain/types/ops/NodePropSet.ts'; +import PropSet from '../../../src/domain/types/ops/PropSet.ts'; +import type { PatchOp } from '../../../src/domain/types/ops/unions.ts'; import WarpWorldline from '../../../src/domain/WarpWorldline.ts'; import { testDerivedIntentAdmissionReceipt } from '../../helpers/intentAdmission.ts'; -import { createPatchBuilder } from './services/PatchBuilderTestHarness.ts'; +import { + createPatchBuilder, + createPatchBuilderMockPersistence, + createPatchJournal, +} from './services/PatchBuilderTestHarness.ts'; describe('WriteRuntime admission classification', () => { + it('issues the substrate occurrence from the causally published entity patch', async () => { + const { context, provenance } = createContext(); + const receipt = await executeIntentWrite({ + runtime: createRuntime(), + context, + intent: intent.entity.addAuto({ + namespace: 'entry', + properties: { kind: 'capture', capturedAt: '2026-08-03T20:00:00.000Z' }, + }), + commit: async (build) => { + const capture = committableBuilder(); + await build(capture); + return await capture.commitWithEvidence(); + }, + }); + + const occurrence = receipt.occurrence; + expect(occurrence).toBeDefined(); + if (occurrence === undefined) { + throw new Error('entity write must return an occurrence'); + } + expect(occurrence.subject).toMatch(/^entry:[0-9a-f]+$/); + expect(occurrence.id).toMatch(/^occurrence:[0-9a-f]+$/); + expect(occurrence.relationTo(occurrence)).toBe('same'); + expect(occurrence.compare(occurrence)).toBe(0); + expect(provenance).toEqual([{ operation: 'write', patchSha: expect.any(String) }]); + }); + + it('refuses a published entity receipt whose patch lost its NodeAdd coordinate', async () => { + await expect( + executeIntentWrite({ + runtime: createRuntime(), + context: createContext().context, + intent: intent.entity.add({ + subject: 'entry:1', + properties: { kind: 'capture' }, + }), + commit: async (build) => { + const capture = committableBuilder(); + await build(capture); + const publication = await capture.commitWithEvidence(); + const replacement = patchWithOps(publication.patch, []); + expectPreservedPatchMetadata(replacement, publication.patch); + return Object.freeze({ ...publication, patch: replacement }); + }, + }) + ).rejects.toMatchObject({ code: 'E_WRITE_ENTITY_OCCURRENCE' }); + }); + + it('refuses a published entity receipt whose patch is not an entity capture', async () => { + await expect( + executeIntentWrite({ + runtime: createRuntime(), + context: createContext().context, + intent: intent.entity.add({ + subject: 'entry:1', + properties: { kind: 'capture' }, + }), + commit: async (build) => { + const capture = committableBuilder(); + await build(capture); + const publication = await capture.commitWithEvidence(); + const leading = requireNodeAdd(publication.patch.ops[0]); + const replacement = patchWithOps(publication.patch, [leading]); + expect(replacement.ops[0]).toBe(leading); + expectPreservedPatchMetadata(replacement, publication.patch); + return Object.freeze({ ...publication, patch: replacement }); + }, + }) + ).rejects.toMatchObject({ code: 'E_WRITE_ENTITY_OCCURRENCE' }); + }); + + it('refuses a published entity receipt whose supplied subject changed', async () => { + await expect( + executeIntentWrite({ + runtime: createRuntime(), + context: createContext().context, + intent: intent.entity.add({ + subject: 'entry:1', + properties: { kind: 'capture' }, + }), + commit: async (build) => { + const capture = committableBuilder(); + await build(capture); + const publication = await capture.commitWithEvidence(); + const replacement = renameEntitySubject(publication.patch, 'entry:1', 'entry:2'); + expect(requireNodeAdd(replacement.ops[0]).dot).toBe( + requireNodeAdd(publication.patch.ops[0]).dot + ); + expectPreservedPatchMetadata(replacement, publication.patch, ['entry:2']); + return Object.freeze({ + ...publication, + patch: replacement, + }); + }, + }) + ).rejects.toMatchObject({ code: 'E_WRITE_ENTITY_OCCURRENCE' }); + }); + + it('refuses a published entity receipt whose allocated subject changed', async () => { + await expect( + executeIntentWrite({ + runtime: createRuntime(), + context: createContext().context, + intent: intent.entity.addAuto({ + namespace: 'entry', + properties: { kind: 'capture' }, + }), + commit: async (build) => { + const capture = committableBuilder(); + await build(capture); + const publication = await capture.commitWithEvidence(); + const originalSubject = requireNodeAdd(publication.patch.ops[0]).node; + const replacement = renameEntitySubject( + publication.patch, + originalSubject, + 'entry:attacker-selected' + ); + expect(requireNodeAdd(replacement.ops[0]).dot).toBe( + requireNodeAdd(publication.patch.ops[0]).dot + ); + expectPreservedPatchMetadata(replacement, publication.patch, ['entry:attacker-selected']); + return Object.freeze({ + ...publication, + patch: replacement, + }); + }, + }) + ).rejects.toMatchObject({ code: 'E_WRITE_ENTITY_OCCURRENCE' }); + }); + + it('refuses a supplied-subject publication whose payload changed', async () => { + await expect( + executeIntentWrite({ + runtime: createRuntime(), + context: createContext().context, + intent: intent.entity.add({ + subject: 'entry:1', + properties: { kind: 'requested' }, + }), + commit: async (build) => { + const capture = committableBuilder(); + await build(capture); + const publication = await capture.commitWithEvidence(); + const replacement = replaceEntityProperty(publication.patch, 'kind', 'substituted'); + expect(replacement.ops[0]).toBe(publication.patch.ops[0]); + expectPreservedPatchMetadata(replacement, publication.patch); + return Object.freeze({ + ...publication, + patch: replacement, + }); + }, + }) + ).rejects.toMatchObject({ code: 'E_WRITE_ENTITY_OCCURRENCE' }); + }); + + it('refuses an auto-allocated publication whose payload changed', async () => { + await expect( + executeIntentWrite({ + runtime: createRuntime(), + context: createContext().context, + intent: intent.entity.addAuto({ + namespace: 'entry', + properties: { kind: 'requested' }, + }), + commit: async (build) => { + const capture = committableBuilder(); + await build(capture); + const publication = await capture.commitWithEvidence(); + const replacement = replaceEntityProperty(publication.patch, 'kind', 'substituted'); + expect(replacement.ops[0]).toBe(publication.patch.ops[0]); + expectPreservedPatchMetadata(replacement, publication.patch); + return Object.freeze({ + ...publication, + patch: replacement, + }); + }, + }) + ).rejects.toMatchObject({ code: 'E_WRITE_ENTITY_OCCURRENCE' }); + }); + it('classifies writer CAS races as stale-basis obstructions', async () => { const { context, provenance } = createContext(); const receipt = await executeIntentWrite({ @@ -140,6 +330,83 @@ function builder(overrides: Parameters[0] = {}): Patc }); } +function committableBuilder(): PatchBuilder { + const persistence = createPatchBuilderMockPersistence(); + return builder({ persistence, patchJournal: createPatchJournal(persistence) }); +} + +function requireNodeAdd(op: object | undefined): NodeAdd { + if (op instanceof NodeAdd) { + return op; + } + throw new Error('expected a leading NodeAdd'); +} + +function patchWithOps( + patch: Patch, + ops: PatchOp[], + writes: string[] | undefined = patch.writes +): Patch { + return new Patch({ + schema: patch.schema, + writer: patch.writer, + lamport: patch.lamport, + context: patch.context, + ops, + reads: patch.reads, + writes, + }); +} + +function renameEntitySubject(patch: Patch, from: string, to: string): Patch { + const ops = patch.ops.map((op) => renameEntityOperation(op, from, to)); + const writes = patch.writes?.map((subject) => (subject === from ? to : subject)); + return patchWithOps(patch, ops, writes); +} + +function renameEntityOperation(op: PatchOp, from: string, to: string): PatchOp { + if (op instanceof NodeAdd && op.node === from) { + return new NodeAdd(to, op.dot); + } + if (op instanceof NodePropSet && op.node === from) { + return new NodePropSet(to, op.key, op.value); + } + if (op instanceof PropSet && op.node === from) { + return new PropSet(to, op.key, op.value); + } + return op; +} + +function replaceEntityProperty(patch: Patch, key: string, value: string): Patch { + return patchWithOps( + patch, + patch.ops.map((op) => replaceEntityPropertyOperation(op, key, value)) + ); +} + +function replaceEntityPropertyOperation(op: PatchOp, key: string, value: string): PatchOp { + if (op instanceof NodePropSet && op.key === key) { + return new NodePropSet(op.node, op.key, value); + } + if (op instanceof PropSet && op.key === key) { + return new PropSet(op.node, op.key, value); + } + return op; +} + +function expectPreservedPatchMetadata( + replacement: Patch, + publication: Patch, + writes: string[] | undefined = publication.writes +): void { + expect(replacement.schema).toBe(publication.schema); + expect(replacement.writer).toBe(publication.writer); + expect(replacement.lamport).toBe(publication.lamport); + expect(replacement.context).toEqual(publication.context); + expect(replacement.reads).toEqual(publication.reads); + expect(replacement.writes).toEqual(writes); +} + function stateWithAttachedEdge(): WarpState { const state = WarpState.empty(); state.nodeAlive.add('user:alice', Dot.create('agent-1', 1)); diff --git a/test/unit/domain/crdt/Dot.test.ts b/test/unit/domain/crdt/Dot.test.ts index 17ca2307a..37aab63af 100644 --- a/test/unit/domain/crdt/Dot.test.ts +++ b/test/unit/domain/crdt/Dot.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { Dot, +import { + Dot, dotsEqual, encodeDot, decodeDot, @@ -31,25 +32,32 @@ describe('Dot', () => { }); it('throws on non-string writerId', () => { - expect(() => Dot.create((123 as any), 1)).toThrow('writerId must be a non-empty string'); - expect(() => Dot.create((null as any), 1)).toThrow('writerId must be a non-empty string'); - expect(() => Dot.create((undefined as any), 1)).toThrow('writerId must be a non-empty string'); + // @ts-expect-error Exercise the JavaScript boundary with a number. + expect(() => Dot.create(123, 1)).toThrow('writerId must be a non-empty string'); + // @ts-expect-error Exercise the JavaScript boundary with null. + expect(() => Dot.create(null, 1)).toThrow('writerId must be a non-empty string'); + // @ts-expect-error Exercise the JavaScript boundary with undefined. + expect(() => Dot.create(undefined, 1)).toThrow('writerId must be a non-empty string'); }); it('throws on non-positive counter', () => { - expect(() => Dot.create('alice', 0)).toThrow('counter must be a positive integer'); - expect(() => Dot.create('alice', -1)).toThrow('counter must be a positive integer'); + expect(() => Dot.create('alice', 0)).toThrow('counter must be a positive safe integer'); + expect(() => Dot.create('alice', -1)).toThrow('counter must be a positive safe integer'); }); it('throws on non-integer counter', () => { - expect(() => Dot.create('alice', 1.5)).toThrow('counter must be a positive integer'); - expect(() => Dot.create('alice', NaN)).toThrow('counter must be a positive integer'); - expect(() => Dot.create('alice', Infinity)).toThrow('counter must be a positive integer'); + expect(() => Dot.create('alice', 1.5)).toThrow('counter must be a positive safe integer'); + expect(() => Dot.create('alice', NaN)).toThrow('counter must be a positive safe integer'); + expect(() => Dot.create('alice', Infinity)).toThrow( + 'counter must be a positive safe integer' + ); }); it('throws on non-number counter', () => { - expect(() => Dot.create('alice', ('1' as any))).toThrow('counter must be a positive integer'); - expect(() => Dot.create('alice', (null as any))).toThrow('counter must be a positive integer'); + // @ts-expect-error Exercise the JavaScript boundary with a string. + expect(() => Dot.create('alice', '1')).toThrow('counter must be a positive safe integer'); + // @ts-expect-error Exercise the JavaScript boundary with null. + expect(() => Dot.create('alice', null)).toThrow('counter must be a positive safe integer'); }); }); @@ -176,6 +184,22 @@ describe('Dot', () => { expect(() => decodeDot('alice:-1')).toThrow('Invalid encoded dot format: invalid counter'); }); + it.each([ + 'alice:1garbage', + 'alice:1.5', + 'alice:1e3', + 'alice:+1', + 'alice:01', + 'alice: 1', + 'alice:1 ', + ])('rejects non-canonical counter spelling %s', (encoded) => { + expect(() => decodeDot(encoded)).toThrowError( + expect.objectContaining({ + code: 'E_CRDT_INVALID_COUNTER', + }) + ); + }); + it('roundtrips with encodeDot', () => { const original = Dot.create('alice', 42); const encoded = encodeDot(original); @@ -288,6 +312,12 @@ describe('Dot', () => { expect(dotsEqual(dot, decoded)).toBe(true); }); + it('rejects counters beyond exact integer representation', () => { + expect(() => Dot.create('alice', Number.MAX_SAFE_INTEGER + 1)).toThrowError( + expect.objectContaining({ code: 'E_CRDT_INVALID_COUNTER' }) + ); + }); + it('handles unicode writerId', () => { const dot = Dot.create('writer-\u4e2d\u6587', 1); diff --git a/test/unit/domain/crdt/VersionVector.test.ts b/test/unit/domain/crdt/VersionVector.test.ts index 8dacec61c..f13935ed0 100644 --- a/test/unit/domain/crdt/VersionVector.test.ts +++ b/test/unit/domain/crdt/VersionVector.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'; import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; import { Dot } from '../../../../src/domain/crdt/Dot.ts'; - describe('VersionVector', () => { describe('empty', () => { it('creates an empty version vector', () => { @@ -61,6 +60,17 @@ describe('VersionVector', () => { expect(dot.writerId).toBe('alice'); expect(dot.counter).toBe(1); }); + + it('refuses exhaustion without reissuing the terminal writer Dot', () => { + const vv = VersionVector.from({ alice: Number.MAX_SAFE_INTEGER }); + + expect(() => vv.increment('alice')).toThrowError( + expect.objectContaining({ + code: 'E_CRDT_INVALID_COUNTER', + }) + ); + expect(vv.get('alice')).toBe(Number.MAX_SAFE_INTEGER); + }); }); describe('merge', () => { @@ -311,10 +321,28 @@ describe('VersionVector', () => { expect(keys).toEqual(['alice', 'bob', 'charlie']); }); + it('preserves prototype-named writers as own data properties', () => { + const vv = VersionVector.empty(); + vv.set('__proto__', 1); + vv.set('constructor', 2); + vv.set('prototype', 3); + + const serialized = VersionVector.serialize(vv); + + expect(Object.keys(serialized)).toEqual(['__proto__', 'constructor', 'prototype']); + expect(Object.hasOwn(serialized, '__proto__')).toBe(true); + expect(serialized['__proto__']).toBe(1); + expect(Object.hasOwn(serialized, 'constructor')).toBe(true); + expect(serialized.constructor).toBe(2); + expect(Object.hasOwn(serialized, 'prototype')).toBe(true); + expect(serialized['prototype']).toBe(3); + expect(VersionVector.from(serialized).equals(vv)).toBe(true); + }); + it('deserializes empty object', () => { const obj = {}; - const vv = VersionVector.from((obj)); + const vv = VersionVector.from(obj); expect(vv.size).toBe(0); }); @@ -344,9 +372,13 @@ describe('VersionVector', () => { }); it('throws on invalid counter', () => { - expect(() => VersionVector.from(({ alice: 'not a number' } as any))).toThrow('Invalid counter'); + // @ts-expect-error Exercise the JavaScript boundary with a string counter. + expect(() => VersionVector.from({ alice: 'not a number' })).toThrow('Invalid counter'); expect(() => VersionVector.from({ alice: 1.5 })).toThrow('Invalid counter'); expect(() => VersionVector.from({ alice: -1 })).toThrow('Invalid counter'); + expect(() => VersionVector.from({ alice: Number.MAX_SAFE_INTEGER + 1 })).toThrow( + 'Invalid counter' + ); }); it('roundtrips', () => { diff --git a/test/unit/domain/services/PatchBuilder.commit.test.ts b/test/unit/domain/services/PatchBuilder.commit.test.ts index d0aa3a016..490cd7f07 100644 --- a/test/unit/domain/services/PatchBuilder.commit.test.ts +++ b/test/unit/domain/services/PatchBuilder.commit.test.ts @@ -1,13 +1,15 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; import { encodeEdgeKey } from '../../../../src/domain/services/JoinReducer.ts'; import AssetHandle from '../../../../src/domain/storage/AssetHandle.ts'; +import type { StagedAsset } from '../../../../src/ports/AssetStoragePort.ts'; import { createGitCasPatchStorage } from '../../../../src/ports/CommitMessageCodecPort.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import { createPatchBuilder, createPatchBuilderMockPersistence as createMockPersistence, createPatchJournal, + RecordingAssetStorage, } from './PatchBuilderTestHarness.ts'; describe('PatchBuilder semantic commit', () => { @@ -82,7 +84,7 @@ describe('PatchBuilder semantic commit', () => { schema: 2, patchHandle: new AssetHandle('asset:parent'), storage: createGitCasPatchStorage({ encrypted: false }), - }), + }) ), }); const patchJournal = createPatchJournal(persistence); @@ -108,11 +110,13 @@ describe('PatchBuilder semantic commit', () => { it('preserves attachment handles in the publication request', async () => { const persistence = createMockPersistence(); const patchJournal = createPatchJournal(persistence); - const builder = createPatchBuilder({ persistence, patchJournal }); + const builder = createPatchBuilder({ + persistence, + patchJournal, + assetStorage: new RecordingAssetStorage(['asset:attachment']), + }); builder.addNode('node:a'); - (builder as unknown as { _contentAssets: AssetHandle[] })._contentAssets.push( - new AssetHandle('asset:attachment'), - ); + await builder.attachContent('node:a', 'content'); await builder.commit(); @@ -150,6 +154,37 @@ describe('PatchBuilder semantic commit', () => { await expect(pending).resolves.toBe('c'.repeat(40)); }); + it('blocks attachment lowering when publication overtakes asset staging', async () => { + const staged = Promise.withResolvers(); + const assets = new RecordingAssetStorage(); + const stage = vi.spyOn(assets, 'stage').mockImplementation(async () => await staged.promise); + const persistence = createMockPersistence(); + const builder = createPatchBuilder({ + persistence, + patchJournal: createPatchJournal(persistence), + assetStorage: assets, + }); + builder.addNode('node:a'); + + const attachment = builder.attachContent('node:a', 'content'); + await vi.waitFor(() => expect(stage).toHaveBeenCalledOnce()); + await builder.commit(); + staged.resolve( + Object.freeze({ + handle: new AssetHandle('asset:late'), + size: 7, + observedAt: '1970-01-01T00:00:00.000Z', + retention: Object.freeze({ + reachability: 'unanchored', + protection: 'not-established', + }), + }) + ); + + await expect(attachment).rejects.toMatchObject({ code: 'E_PATCH_ALREADY_COMMITTED' }); + expect(builder.ops).toHaveLength(1); + }); + it('allows retry after a storage failure', async () => { const persistence = createMockPersistence(); const patchJournal = createPatchJournal(persistence); @@ -173,8 +208,6 @@ describe('PatchBuilder semantic commit', () => { await builder.commit(); expect(builder.reads).toEqual(new Set(['user:alice', 'user:bob'])); - expect(builder.writes).toEqual(new Set([ - encodeEdgeKey('user:alice', 'user:bob', 'follows'), - ])); + expect(builder.writes).toEqual(new Set([encodeEdgeKey('user:alice', 'user:bob', 'follows')])); }); }); diff --git a/test/unit/domain/services/PatchBuilder.entity.test.ts b/test/unit/domain/services/PatchBuilder.entity.test.ts new file mode 100644 index 000000000..00096b4b8 --- /dev/null +++ b/test/unit/domain/services/PatchBuilder.entity.test.ts @@ -0,0 +1,226 @@ +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 WarpState from '../../../../src/domain/services/state/WarpState.ts'; +import NodeAdd from '../../../../src/domain/types/ops/NodeAdd.ts'; +import PropSet from '../../../../src/domain/types/ops/PropSet.ts'; +import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; +import { + createPatchBuilder, + createPatchBuilderMockPersistence, + createPatchJournal, +} from './PatchBuilderTestHarness.ts'; + +const TEST_SHA = 'a'.repeat(40); + +describe('PatchBuilder entity capture', () => { + it('lowers one entity to a NodeAdd followed by its provided initial payload', () => { + const builder = createBuilder(null); + + builder.addEntity('entry:1785597386985-c538d1bd', { + kind: 'capture', + sortKey: '1785597386985-c538d1bd', + text: 'probe write two', + }); + + const patch = builder.build(); + expect(patch.ops).toHaveLength(4); + expect(patch.ops[0]).toBeInstanceOf(NodeAdd); + expect(requireNodeAdd(patch.ops[0]).node).toBe('entry:1785597386985-c538d1bd'); + expect(patch.ops.slice(1).map((op) => requirePropSet(op).key)).toEqual([ + 'kind', + 'sortKey', + 'text', + ]); + expect(patch.ops.slice(1).map((op) => requirePropSet(op).node)).toEqual( + Array.from({ length: 3 }, () => 'entry:1785597386985-c538d1bd') + ); + expect(requirePropSet(patch.ops[3]).value).toBe('probe write two'); + }); + + it('declares an empty read set and exactly one subject write', () => { + const builder = createBuilder(null); + + builder.addEntity('entry:1', { kind: 'capture', text: 'a fact' }); + + expect([...builder.reads]).toEqual([]); + expect([...builder.writes]).toEqual(['entry:1']); + }); + + it('rejects an entity whose id already exists in the graph', () => { + const builder = createBuilder(stateWithNode('entry:1')); + + expect(() => { + builder.addEntity('entry:1', { kind: 'capture' }); + }).toThrowError( + expect.objectContaining({ + code: 'E_PATCH_ENTITY_EXISTS', + }) + ); + expect(builder.build().ops).toEqual([]); + }); + + it('rejects an entity whose id this patch already wrote', () => { + const builder = createBuilder(null); + builder.addEntity('entry:1', { kind: 'capture' }); + + expect(() => { + builder.addEntity('entry:1', { kind: 'capture' }); + }).toThrowError( + expect.objectContaining({ + code: 'E_PATCH_ENTITY_EXISTS', + }) + ); + }); + + it('requires at least one property so an entity is never an empty shell', () => { + const builder = createBuilder(null); + + expect(() => { + builder.addEntity('entry:1', {}); + }).toThrowError( + expect.objectContaining({ + code: 'E_PATCH_ENTITY_EMPTY', + }) + ); + expect(builder.build().ops).toEqual([]); + }); + + it.each([null, 'capture', ['capture'], new EntityPayloadCarrier()])( + 'rejects non-record entity payload %# before appending any operation', + (payload) => { + const builder = createBuilder(null); + + expect(() => { + // @ts-expect-error Exercise the JavaScript boundary. + builder.addEntity('entry:1', payload); + }).toThrowError(expect.objectContaining({ code: 'E_PATCH_ENTITY_PAYLOAD' })); + expect(builder.build().ops).toEqual([]); + } + ); + + it('rejects invalid property values before appending any operation', () => { + const builder = createBuilder(null); + + expect(() => { + builder.addEntity('entry:1', { + kind: 'capture', + // @ts-expect-error Exercise the JavaScript boundary. + broken: new InvalidPropertyCarrier(), + }); + }).toThrow(PatchError); + expect(builder.build().ops).toEqual([]); + }); + + it('rejects a reserved id before appending any operation', () => { + const builder = createBuilder(null); + + expect(() => { + builder.addEntity('', { kind: 'capture' }); + }).toThrow(/NodeId/); + expect(builder.build().ops).toEqual([]); + }); + + it('copies the payload so later caller mutation cannot rewrite the patch', () => { + const builder = createBuilder(null); + const payload = { tags: ['first'] }; + + builder.addEntity('entry:1', payload); + payload.tags.push('second'); + + const op = requirePropSet(builder.build().ops[1]); + expect(op.key).toBe('tags'); + expect(op.value).toEqual(['first']); + }); + + it('enforces the committed lifecycle before validating entity input', async () => { + const persistence = createPatchBuilderMockPersistence(); + const builder = createPatchBuilder({ + persistence, + patchJournal: createPatchJournal(persistence), + }); + builder.addNode('seed'); + await builder.commitWithEvidence(); + + expect(() => builder.addEntity('entry:1', {})).toThrowError( + expect.objectContaining({ + code: 'E_PATCH_ALREADY_COMMITTED', + }) + ); + }); +}); + +function createBuilder(state: WarpState | null): PatchBuilder { + return new PatchBuilder({ + persistence: unusedPersistence(), + graphName: 'graph', + writerId: 'writer', + lamport: 1, + versionVector: VersionVector.empty(), + getCurrentState: () => state, + }); +} + +function stateWithNode(nodeId: string): WarpState { + const state = WarpState.empty(); + state.nodeAlive.add(nodeId, Dot.create('writer', 1)); + 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 requireNodeAdd(op: object | undefined): NodeAdd { + if (op instanceof NodeAdd) { + return op; + } + throw new PatchError('Expected NodeAdd in test output', { code: 'E_TEST_EXPECTED_NODE_ADD' }); +} + +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 {} + +class EntityPayloadCarrier { + readonly kind = 'capture'; +} diff --git a/test/unit/domain/types/EntityCapturePayload.test.ts b/test/unit/domain/types/EntityCapturePayload.test.ts new file mode 100644 index 000000000..466314de9 --- /dev/null +++ b/test/unit/domain/types/EntityCapturePayload.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import { + entityCapturePayloadsEqual, + isEntityCapturePayloadRecord, +} from '../../../../src/domain/types/EntityCapturePayload.ts'; +import { propValuesEqual, type PropValue } from '../../../../src/domain/types/PropValue.ts'; + +describe('EntityCapturePayload', () => { + it('accepts only plain and null-prototype record boundaries', () => { + const nullPrototype: Record = { kind: 'capture' }; + Object.setPrototypeOf(nullPrototype, null); + + expect(isEntityCapturePayloadRecord({ kind: 'capture' })).toBe(true); + expect(isEntityCapturePayloadRecord(nullPrototype)).toBe(true); + // @ts-expect-error Exercise the JavaScript boundary with null. + expect(isEntityCapturePayloadRecord(null)).toBe(false); + // @ts-expect-error Exercise the JavaScript boundary with a scalar. + expect(isEntityCapturePayloadRecord('capture')).toBe(false); + // @ts-expect-error Exercise the JavaScript boundary with an array. + expect(isEntityCapturePayloadRecord(['capture'])).toBe(false); + // @ts-expect-error Exercise the JavaScript boundary with a class instance. + expect(isEntityCapturePayloadRecord(new PayloadCarrier())).toBe(false); + }); + + it('compares payload keys independently of construction order', () => { + expect( + entityCapturePayloadsEqual( + { kind: 'capture', text: 'hello' }, + { text: 'hello', kind: 'capture' } + ) + ).toBe(true); + expect(entityCapturePayloadsEqual({ kind: 'capture' }, {})).toBe(false); + expect(entityCapturePayloadsEqual({ kind: 'capture' }, { text: 'capture' })).toBe(false); + expect(entityCapturePayloadsEqual({ kind: 'capture' }, { kind: 'other' })).toBe(false); + }); +}); + +describe('propValuesEqual', () => { + it('uses exact scalar identity', () => { + expect(propValuesEqual('capture', 'capture')).toBe(true); + expect(propValuesEqual(Number.NaN, Number.NaN)).toBe(true); + expect(propValuesEqual(0, -0)).toBe(false); + expect(propValuesEqual('1', 1)).toBe(false); + expect(propValuesEqual(null, false)).toBe(false); + }); + + it('compares byte arrays by length and byte value', () => { + expect(propValuesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(true); + expect(propValuesEqual(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe(false); + expect(propValuesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 3]))).toBe(false); + expect(propValuesEqual(new Uint8Array([1]), [1])).toBe(false); + expect(propValuesEqual([1], new Uint8Array([1]))).toBe(false); + }); + + it('compares arrays recursively and in order', () => { + expect( + propValuesEqual( + ['capture', [1, true], { nested: null }], + ['capture', [1, true], { nested: null }] + ) + ).toBe(true); + expect(propValuesEqual([1], [1, 2])).toBe(false); + expect(propValuesEqual([1, 2], [1, 3])).toBe(false); + expect(propValuesEqual([1], 1)).toBe(false); + expect(propValuesEqual(1, [1])).toBe(false); + }); + + it('compares record keys and values recursively', () => { + expect( + propValuesEqual({ b: { nested: true }, a: [1, 2] }, { a: [1, 2], b: { nested: true } }) + ).toBe(true); + expect(propValuesEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); + expect(propValuesEqual({ a: 1 }, { b: 1 })).toBe(false); + expect(propValuesEqual({ a: { nested: true } }, { a: { nested: false } })).toBe(false); + expect(propValuesEqual({ a: 1 }, 1)).toBe(false); + expect(propValuesEqual(1, { a: 1 })).toBe(false); + }); +}); + +class PayloadCarrier { + readonly kind = 'capture'; +} diff --git a/test/unit/infrastructure/adapters/WesleyDotCodecAdapter.test.ts b/test/unit/infrastructure/adapters/WesleyDotCodecAdapter.test.ts index 572d671a5..6605fbf5a 100644 --- a/test/unit/infrastructure/adapters/WesleyDotCodecAdapter.test.ts +++ b/test/unit/infrastructure/adapters/WesleyDotCodecAdapter.test.ts @@ -13,9 +13,19 @@ const codec = new WesleyDotCodecAdapter(); */ function aliceDotBytes(counter: number): Uint8Array { return new Uint8Array([ - 0x05, 0x00, 0x00, 0x00, - 0x61, 0x6c, 0x69, 0x63, 0x65, - counter, 0x00, 0x00, 0x00, + 0x05, + 0x00, + 0x00, + 0x00, + 0x61, + 0x6c, + 0x69, + 0x63, + 0x65, + counter, + 0x00, + 0x00, + 0x00, ]); } @@ -46,13 +56,15 @@ describe('WesleyDotCodecAdapter', () => { }); it('rejects generated transport shapes that violate Dot invariants', () => { - expect(() => codec.decode(aliceDotBytes(0))).toThrow('counter must be a positive integer'); + expect(() => codec.decode(aliceDotBytes(0))).toThrow('counter must be a positive safe integer'); }); it('fails closed when a valid Dot exceeds Wesley GraphQL Int range', () => { const tooLargeForCurrentWesleyInt = new Dot('alice', 0x8000_0000); - expect(() => codec.encode(tooLargeForCurrentWesleyInt)).toThrow('Wesley LE-binary i32 out of range'); + expect(() => codec.encode(tooLargeForCurrentWesleyInt)).toThrow( + 'Wesley LE-binary i32 out of range' + ); }); }); diff --git a/test/unit/scripts/cli-entity-documentation.test.ts b/test/unit/scripts/cli-entity-documentation.test.ts new file mode 100644 index 000000000..dbc96079c --- /dev/null +++ b/test/unit/scripts/cli-entity-documentation.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +describe('CLI entity documentation boundary', () => { + it('keeps parser behavior tests independent from filesystem prose checks', () => { + const parserSuite = readFileSync( + new URL('../cli/v19-entity-intent.test.ts', import.meta.url), + 'utf8' + ); + + expect(parserSuite).not.toContain("from 'node:fs'"); + }); + + it('documents the JSON and TypeScript occurrence surfaces separately', () => { + const guide = readFileSync(new URL('../../../docs/topics/cli.md', import.meta.url), 'utf8'); + + expect(guide).toContain('The CLI JSON envelope exposes only'); + expect(guide).toContain('The in-process TypeScript `EntityOccurrence`'); + }); +}); diff --git a/test/unit/scripts/entity-capture-doctrine.test.ts b/test/unit/scripts/entity-capture-doctrine.test.ts new file mode 100644 index 000000000..be9a471c5 --- /dev/null +++ b/test/unit/scripts/entity-capture-doctrine.test.ts @@ -0,0 +1,44 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const DOCTRINE_FILES = Object.freeze([ + 'CHANGELOG.md', + 'docs/READINGS_AND_OPTICS.md', + 'docs/topics/cli.md', + 'src/domain/api/Intent.ts', + 'src/domain/api/IntentRuntime.ts', + 'src/domain/services/PatchBuilder.ts', + 'src/domain/services/PatchBuilderEntity.ts', + 'src/domain/types/EntityCapturePayload.ts', + 'test/unit/domain/IntentRuntime.entity.test.ts', + 'test/unit/domain/services/PatchBuilder.entity.test.ts', +]); + +const PAYLOAD_WORDING_FILES = Object.freeze([ + 'src/domain/services/PatchBuilderEntity.ts', + 'test/unit/domain/Intent.entity.test.ts', + 'test/unit/domain/services/PatchBuilder.entity.test.ts', +]); + +describe('entity capture doctrine', () => { + it.each(DOCTRINE_FILES)('%s does not overclaim semantic dependency exactness', (path) => { + expect(read(path)).not.toMatch( + /dependency-pure|exact\s+by\s+construction|exactness\s+is\s+guaranteed|syntactic footprint is exact/i + ); + }); + + it('states that a declared empty read set cannot prove the caller made no pre-read', () => { + expect(read('docs/READINGS_AND_OPTICS.md')).toMatch( + /does not prove that\s+application code made no prior graph read/ + ); + }); + + it.each(PAYLOAD_WORDING_FILES)('%s does not claim application payload completeness', (path) => { + expect(read(path)).not.toMatch(/complete (?:initial )?payload/i); + }); +}); + +function read(path: string): string { + return readFileSync(join(process.cwd(), path), 'utf8'); +} diff --git a/test/unit/scripts/entity-capture-type-assertion-ratchet.test.ts b/test/unit/scripts/entity-capture-type-assertion-ratchet.test.ts new file mode 100644 index 000000000..7f77324ce --- /dev/null +++ b/test/unit/scripts/entity-capture-type-assertion-ratchet.test.ts @@ -0,0 +1,130 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +const ENTITY_CAPTURE_FILES = Object.freeze([ + 'src/domain/api/EntityOccurrenceRuntime.ts', + 'src/domain/api/Intent.ts', + 'src/domain/api/IntentRuntime.ts', + 'src/domain/types/EntityCapturePayload.ts', + 'src/domain/types/PropValue.ts', + 'test/integration/application/Runtime.entityCapture.integration.test.ts', + 'test/unit/domain/Intent.entity.test.ts', + 'test/unit/domain/IntentRuntime.entity.test.ts', + 'test/unit/domain/ReceiptOutcome.test.ts', + 'test/unit/domain/WriteRuntime.test.ts', + 'test/unit/domain/types/EntityCapturePayload.test.ts', + 'test/unit/domain/crdt/Dot.test.ts', + 'test/unit/domain/crdt/VersionVector.test.ts', + 'test/unit/domain/services/PatchBuilder.entity.test.ts', +]); + +describe('entity capture type-assertion ratchet', () => { + it('covers the payload comparison implementations', () => { + expect(ENTITY_CAPTURE_FILES).toEqual( + expect.arrayContaining([ + 'src/domain/types/EntityCapturePayload.ts', + 'src/domain/types/PropValue.ts', + ]) + ); + }); + + it('keeps entity implementation and test evidence free of type sludge', () => { + const violations = ENTITY_CAPTURE_FILES.flatMap(typeSludgeIn); + + expect(violations).toEqual([]); + }); + + it('keeps occurrence authority on the occurrence object', () => { + const runtime = readFileSync( + join(process.cwd(), 'src/domain/api/EntityOccurrenceRuntime.ts'), + 'utf8' + ); + const occurrence = readFileSync( + join(process.cwd(), 'src/domain/api/EntityOccurrence.ts'), + 'utf8' + ); + + expect(runtime).not.toMatch(/\bWeakMap\b/); + expect(occurrence).not.toContain('readonly #compare'); + expect(occurrence).not.toContain('readonly #relationTo'); + expect(occurrence).not.toContain('issued.subject === occurrence.subject'); + }); + + it('keeps the opaque occurrence declaration detached from internal coordinates', () => { + const occurrence = readFileSync( + join(process.cwd(), 'src/domain/api/EntityOccurrence.ts'), + 'utf8' + ); + + expect(occurrence).not.toContain("from '../crdt/Dot.ts'"); + expect(occurrence).not.toContain("from '../utils/EventId.ts'"); + }); + + it('keeps permissive PatchBuilder values method-generic', () => { + const sourceFile = sourceFileFor('src/domain/services/PatchBuilder.ts'); + const builder = sourceFile.statements.find( + (statement): statement is ts.ClassDeclaration => + ts.isClassDeclaration(statement) && statement.name?.text === 'PatchBuilder' + ); + + expect(builder).toBeDefined(); + expect(genericValueType(builder, 'emitEffect', 'payload')).toBe('T'); + expect(genericValueType(builder, 'setProperty', 'value')).toBe('T'); + expect(genericValueType(builder, 'setEdgeProperty', 'value')).toBe('T'); + }); +}); + +function sourceFileFor(relativePath: string): ts.SourceFile { + const source = readFileSync(join(process.cwd(), relativePath), 'utf8'); + return ts.createSourceFile(relativePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); +} + +function genericValueType( + declaration: ts.ClassDeclaration | undefined, + methodName: string, + parameterName: string +): string | undefined { + const method = declaration?.members.find( + (member): member is ts.MethodDeclaration => + ts.isMethodDeclaration(member) && member.name.getText() === methodName + ); + const parameter = method?.parameters.find( + (candidate) => candidate.name.getText() === parameterName + ); + const typeParameter = method?.typeParameters?.[0]?.name.text; + const parameterType = parameter?.type?.getText(); + return typeParameter === parameterType ? parameterType : undefined; +} + +function typeSludgeIn(relativePath: string): string[] { + const source = readFileSync(join(process.cwd(), relativePath), 'utf8'); + const sourceFile = ts.createSourceFile( + relativePath, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + const violations: string[] = []; + visit(sourceFile); + return violations; + + function visit(node: ts.Node): void { + if (isTypeSludge(node)) { + const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart()); + violations.push(`${relativePath}:${line + 1}:${character + 1}`); + } + ts.forEachChild(node, visit); + } +} + +function isTypeSludge(node: ts.Node): boolean { + return ( + ts.isAsExpression(node) || + ts.isTypeAssertionExpression(node) || + node.kind === ts.SyntaxKind.AnyKeyword || + node.kind === ts.SyntaxKind.UnknownKeyword + ); +} diff --git a/test/unit/scripts/git-machine-local-path-guard.test.ts b/test/unit/scripts/git-machine-local-path-guard.test.ts new file mode 100644 index 000000000..291aa2d71 --- /dev/null +++ b/test/unit/scripts/git-machine-local-path-guard.test.ts @@ -0,0 +1,135 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { GitMachineLocalPathGuard } from '../../../scripts/GitMachineLocalPathGuard.ts'; +import { MachineLocalPathPolicy } from '../../../scripts/MachineLocalPathPolicy.ts'; + +const hookPath = fileURLToPath(new URL('../../../scripts/hooks/pre-commit', import.meta.url)); +const ciPath = fileURLToPath(new URL('../../../.github/workflows/ci.yml', import.meta.url)); +const tempDirs: string[] = []; + +function git(repository: string, ...args: readonly string[]): void { + execFileSync('git', args, { cwd: repository, stdio: 'pipe' }); +} + +function gitText(repository: string, ...args: readonly string[]): string { + return execFileSync('git', args, { cwd: repository, encoding: 'utf8' }).trim(); +} + +function createRepository(): string { + const repository = mkdtempSync(join(tmpdir(), 'git-warp-path-guard-')); + tempDirs.push(repository); + git(repository, 'init', '--quiet'); + git(repository, 'config', 'user.name', 'Path Guard Test'); + git(repository, 'config', 'user.email', 'path-guard@example.invalid'); + return repository; +} + +function personalHome(...segments: readonly string[]): string { + return ['', 'Users', 'example', ...segments].join('/'); +} + +afterEach(() => { + while (tempDirs.length > 0) { + const directory = tempDirs.pop(); + if (directory !== undefined) { + rmSync(directory, { force: true, recursive: true }); + } + } +}); + +describe('Git machine-local path guard', () => { + it('scans exact staged blobs instead of mutable working-tree bytes', () => { + const repository = createRepository(); + const fixturePath = join(repository, 'fixture.txt'); + writeFileSync(fixturePath, personalHome('git', 'project'), 'utf8'); + git(repository, 'add', 'fixture.txt'); + writeFileSync(fixturePath, 'portable content', 'utf8'); + + const guard = new GitMachineLocalPathGuard(repository, new MachineLocalPathPolicy()); + + expect(guard.findStagedPaths()).toEqual(['fixture.txt']); + expect(guard.findWorkingTreePaths()).toEqual([]); + }); + + it('runs the exact-index scanner from the pre-commit hook', () => { + const hook = readFileSync(hookPath, 'utf8'); + + expect(hook).toContain('node scripts/check-machine-local-paths.ts --staged'); + }); + + it('detects machine-local paths embedded in binary blobs', () => { + const repository = createRepository(); + const fixturePath = join(repository, 'fixture.bin'); + const fixture = Buffer.concat([ + Buffer.from([0]), + Buffer.from(personalHome('build', 'artifact'), 'utf8'), + Buffer.from([0]), + ]); + writeFileSync(fixturePath, fixture); + git(repository, 'add', 'fixture.bin'); + + const guard = new GitMachineLocalPathGuard(repository, new MachineLocalPathPolicy()); + + expect(guard.findWorkingTreePaths()).toEqual(['fixture.bin']); + expect(guard.findStagedPaths()).toEqual(['fixture.bin']); + }); + + it('finds leaked objects even when a later commit makes the branch tip safe', () => { + const repository = createRepository(); + const fixturePath = join(repository, 'fixture.txt'); + writeFileSync(fixturePath, 'portable base', 'utf8'); + git(repository, 'add', 'fixture.txt'); + git(repository, 'commit', '--quiet', '-m', 'safe base'); + const remoteObject = gitText(repository, 'rev-parse', 'HEAD'); + + writeFileSync(fixturePath, personalHome('git', 'project'), 'utf8'); + git(repository, 'add', 'fixture.txt'); + git(repository, 'commit', '--quiet', '-m', 'unsafe middle'); + const leakedBlob = gitText(repository, 'rev-parse', 'HEAD:fixture.txt'); + + writeFileSync(fixturePath, 'portable tip', 'utf8'); + git(repository, 'add', 'fixture.txt'); + git(repository, 'commit', '--quiet', '-m', 'safe tip'); + const localObject = gitText(repository, 'rev-parse', 'HEAD'); + const pushUpdate = `refs/heads/main ${localObject} refs/heads/main ${remoteObject}\n`; + const guard = new GitMachineLocalPathGuard(repository, new MachineLocalPathPolicy()); + + expect(guard.findOutgoingObjects(pushUpdate, 'origin')).toContain(`blob:${leakedBlob}`); + }); + + it('runs the exact outgoing-object scanner from the pre-push hook', () => { + const hookPath = fileURLToPath(new URL('../../../scripts/hooks/pre-push', import.meta.url)); + const hook = readFileSync(hookPath, 'utf8'); + + expect(hook).toContain('node scripts/check-machine-local-paths.ts --pre-push "$REMOTE_NAME"'); + }); + + it('scans an exact committed tree instead of mutable working-tree bytes', () => { + const repository = createRepository(); + const fixturePath = join(repository, 'fixture.txt'); + writeFileSync(fixturePath, personalHome('git', 'project'), 'utf8'); + git(repository, 'add', 'fixture.txt'); + git(repository, 'commit', '--quiet', '-m', 'committed leak'); + const committedObject = gitText(repository, 'rev-parse', 'HEAD'); + writeFileSync(fixturePath, 'portable working tree', 'utf8'); + + const guard = new GitMachineLocalPathGuard(repository, new MachineLocalPathPolicy()); + + expect(guard.findTreePaths(committedObject)).toEqual(['fixture.txt']); + expect(guard.findWorkingTreePaths()).toEqual([]); + }); + + it('makes exact-tree path hygiene a dedicated required CI lane', () => { + const workflow = readFileSync(ciPath, 'utf8'); + + expect(workflow).toContain('type-firewall-path-hygiene:'); + expect(workflow).toContain('node scripts/check-machine-local-paths.ts --tree "$GITHUB_SHA"'); + expect(workflow).toContain('- type-firewall-path-hygiene'); + expect(workflow).toContain("needs['type-firewall-path-hygiene'].result"); + }); +}); diff --git a/test/unit/scripts/machine-local-path-policy.test.ts b/test/unit/scripts/machine-local-path-policy.test.ts new file mode 100644 index 000000000..6e6ba6ba0 --- /dev/null +++ b/test/unit/scripts/machine-local-path-policy.test.ts @@ -0,0 +1,65 @@ +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import packageJson from '../../../package.json' with { type: 'json' }; +import { MachineLocalPathPolicy } from '../../../scripts/MachineLocalPathPolicy.ts'; + +const ROOT = fileURLToPath(new URL('../../../', import.meta.url)); + +function posixPath(...segments: readonly string[]): string { + return ['', ...segments].join('/'); +} + +function windowsPath(...segments: readonly string[]): string { + return ['C:', ...segments].join('\\'); +} + +describe('machine-local path policy', () => { + it('runs the tracked-file guard in the ordinary lint gate', () => { + expect(packageJson.scripts['lint:machine-paths']).toBe( + 'node scripts/check-machine-local-paths.ts' + ); + expect(packageJson.scripts.lint).toContain('npm run lint:machine-paths'); + }); + + it('recognizes personal homes and Darwin temporary roots', () => { + const policy = new MachineLocalPathPolicy(); + + expect(policy.containsMachineLocalPath(posixPath('Users', 'example', 'git', 'project'))).toBe( + true + ); + expect(policy.containsMachineLocalPath(posixPath('home', 'example', 'git', 'project'))).toBe( + true + ); + expect(policy.containsMachineLocalPath(windowsPath('Users', 'example', 'git', 'project'))).toBe( + true + ); + expect(policy.containsMachineLocalPath(windowsPath('users', 'example', 'git', 'project'))).toBe( + true + ); + expect( + policy.containsMachineLocalPath(posixPath('private', 'var', 'folders', 'xy', 'session')) + ).toBe(true); + }); + + it('allows portable and system-owned paths', () => { + const policy = new MachineLocalPathPolicy(); + + expect(policy.containsMachineLocalPath('~/git/project')).toBe(false); + expect(policy.containsMachineLocalPath('$HOME/git/project')).toBe(false); + expect(policy.containsMachineLocalPath('/git/project')).toBe(false); + expect(policy.containsMachineLocalPath('/usr/local/bin/tool')).toBe(false); + expect(policy.containsMachineLocalPath('/tmp/project')).toBe(false); + }); + + it('passes the current tracked and unignored repository inventory', () => { + expect(() => + execFileSync(process.execPath, [join(ROOT, 'scripts/check-machine-local-paths.ts')], { + cwd: ROOT, + stdio: 'pipe', + }) + ).not.toThrow(); + }); +}); diff --git a/test/unit/scripts/v18-to-v19-finalization.test.ts b/test/unit/scripts/v18-to-v19-finalization.test.ts index 5042d610c..3c1dc2fc1 100644 --- a/test/unit/scripts/v18-to-v19-finalization.test.ts +++ b/test/unit/scripts/v18-to-v19-finalization.test.ts @@ -22,6 +22,7 @@ import { openScratchGraph } from '../../../scripts/v18-to-v19/V18MigrationScratc import { restoreV18RetainedSubstrateFixture } from '../../../scripts/v18-to-v19/V18RetainedSubstrateFixtureRestore.ts'; const MANIFEST_PATH = resolve('fixtures/v18/retained-substrate-golden/manifest.json'); +const OVERSIZED_STATE_VERIFICATION_TIMEOUT_MS = 120_000; describe('v18-to-v19 finalization boundaries', () => { const temporaryDirectories: string[] = []; @@ -111,50 +112,58 @@ describe('v18-to-v19 finalization boundaries', () => { ).not.toEqual([]); }); - it('verifies a promoted repository without decoding its oversized full state', async () => { - const migration = await prepareFixtureMigration(); - const opened = await openScratchGraph( - migration.prepared.scratchPath, - migration.graph, - 'oversized-state-writer' - ); - try { - await opened.graph.patch((patch) => { - patch - .addNode('oversized-state-node-a') - .setProperty('oversized-state-node-a', 'payload', 'a'.repeat(3 * 1024 * 1024)); - }); - await opened.graph.patch((patch) => { - patch - .addNode('oversized-state-node-b') - .setProperty('oversized-state-node-b', 'payload', 'b'.repeat(3 * 1024 * 1024)); - }); - await opened.graph.materialize(); - await opened.graph.createCheckpoint(); - } finally { - await opened.close(); - } + it( + 'verifies a promoted repository without decoding its oversized full state', + async () => { + const migration = await prepareFixtureMigration(); + const opened = await openScratchGraph( + migration.prepared.scratchPath, + migration.graph, + 'oversized-state-writer' + ); + try { + await opened.graph.patch((patch) => { + patch + .addNode('oversized-state-node-a') + .setProperty('oversized-state-node-a', 'payload', 'a'.repeat(3 * 1024 * 1024)); + }); + await opened.graph.patch((patch) => { + patch + .addNode('oversized-state-node-b') + .setProperty('oversized-state-node-b', 'payload', 'b'.repeat(3 * 1024 * 1024)); + }); + await opened.graph.materialize(); + await opened.graph.createCheckpoint(); + } finally { + await opened.close(); + } - const eagerControl = await openScratchGraph( - migration.prepared.scratchPath, - migration.graph, - 'oversized-state-eager-control' - ); - try { - await expect(eagerControl.graph.materialize()).rejects.toMatchObject({ - code: 'E_CBOR_DECODE_BOUNDS', - }); - } finally { - await eagerControl.close(); - } + const eagerControl = await openScratchGraph( + migration.prepared.scratchPath, + migration.graph, + 'oversized-state-eager-control' + ); + try { + await expect(eagerControl.graph.materialize()).rejects.toMatchObject({ + code: 'E_CBOR_DECODE_BOUNDS', + }); + } finally { + await eagerControl.close(); + } - const verificationRoot = await mkdtemp(join(tmpdir(), 'git-warp-v18-verify-root-')); - temporaryDirectories.push(verificationRoot); - await expect( - verifyPromotedV19Repository(migration.prepared.scratchPath, migration.graph, verificationRoot) - ).resolves.toBeUndefined(); - expect(await readdir(verificationRoot)).toEqual([]); - }); + const verificationRoot = await mkdtemp(join(tmpdir(), 'git-warp-v18-verify-root-')); + temporaryDirectories.push(verificationRoot); + await expect( + verifyPromotedV19Repository( + migration.prepared.scratchPath, + migration.graph, + verificationRoot + ) + ).resolves.toBeUndefined(); + expect(await readdir(verificationRoot)).toEqual([]); + }, + OVERSIZED_STATE_VERIFICATION_TIMEOUT_MS + ); async function prepareFixtureMigration(): Promise< Readonly<{ diff --git a/test/unit/scripts/v19-public-api-boundary.test.ts b/test/unit/scripts/v19-public-api-boundary.test.ts index ce3d81a88..b83471f1b 100644 --- a/test/unit/scripts/v19-public-api-boundary.test.ts +++ b/test/unit/scripts/v19-public-api-boundary.test.ts @@ -11,6 +11,8 @@ const ROOT_TYPE_EXPORTS = [ 'CoordinateReference', 'Evidence', 'EvidenceHandle', + 'EntityCausalRelation', + 'EntityOccurrence', 'Intent', 'Lane', 'LaneDescriptor', diff --git a/test/unit/scripts/wesley-ci-install-source.test.ts b/test/unit/scripts/wesley-ci-install-source.test.ts new file mode 100644 index 000000000..67fc08610 --- /dev/null +++ b/test/unit/scripts/wesley-ci-install-source.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const ROOT = fileURLToPath(new URL('../../../', import.meta.url)); +const CI_WORKFLOW = readFileSync( + join(ROOT, '.github/workflows/ci.yml'), + 'utf8', +); +const WESLEY_INSTALL_STEP_NAME = + "- name: 'Gate 5a: Install pinned Wesley generator'"; +const NEXT_STEP_NAME = "- name: 'Gate 5b: Wesley vocabulary IR drift'"; + +function wesleyInstallStep(): string { + const start = CI_WORKFLOW.indexOf(WESLEY_INSTALL_STEP_NAME); + const end = CI_WORKFLOW.indexOf(NEXT_STEP_NAME, start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return CI_WORKFLOW.slice(start, end); +} + +describe('Wesley CI install source', () => { + it('installs the exact released CLI from crates.io', () => { + const step = wesleyInstallStep(); + + expect(step).toContain('cargo install'); + expect(step).toContain('--version 0.3.0-alpha.1'); + expect(step).toContain('--locked'); + expect(step).toContain('wesley-cli'); + expect(step).not.toContain('--git'); + expect(step).not.toContain('--rev'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index dc0b09566..0a31251ee 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,16 +9,8 @@ export default defineConfig({ }, test: { setupFiles: ['./test/helpers/runtimeHostCommitMessageCodecSetup.ts'], - include: [ - '**/*.{test,spec}.?(c|m)[jt]s?(x)', - '**/benchmark/*.benchmark.ts', - ], - exclude: [ - '**/node_modules/**', - '**/dist/**', - 'test/runtime/deno/**', - '.claude/**', - ], + include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)', '**/benchmark/*.benchmark.ts'], + exclude: ['**/node_modules/**', '**/dist/**', 'test/runtime/deno/**', '.claude/**'], testTimeout: 60000, // 60s timeout for benchmark tests server: { deps: { @@ -30,7 +22,7 @@ export default defineConfig({ include: ['src/**/*.ts'], exclude: ['src/ports/**/*.ts', 'src/**/*.d.ts'], thresholds: { - lines: 92.97, + lines: 92.99, autoUpdate: shouldAutoUpdateCoverageRatchet(), }, },