From e663754bf221784f0e5856a41fe071bebfa5befb Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 24 Aug 2026 14:13:10 -0700 Subject: [PATCH 1/2] feat: complete compound workspace admission --- ARCHITECTURE.md | 12 +- BEARING.md | 7 +- CHANGELOG.md | 22 + GUIDE.md | 19 +- README.md | 8 +- ROADMAP.md | 9 + STATUS.md | 6 + .../compound-workspace-assets.md | 503 ++++++++++++++++++ docs/design/README.md | 1 + index.d.ts | 6 + src/domain/services/AssetService.js | 5 + .../services/StagingWorkspaceRegistry.js | 2 +- .../services/WorkspaceCompoundAdmission.js | 85 ++- src/domain/services/WorkspaceCompoundScope.js | 27 +- .../compound-workspace-admission.test.js | 76 ++- .../services/AssetService.batch.test.js | 18 + .../StagingWorkspace.compound.test.js | 122 ++++- .../services/WorkspaceCompoundScope.test.js | 52 +- test/unit/types/declaration-accuracy.test.js | 3 + 19 files changed, 936 insertions(+), 47 deletions(-) create mode 100644 docs/design/0061-compound-workspace-assets/compound-workspace-assets.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 00e41afe..beafdbbc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -185,11 +185,13 @@ The facade is orchestration glue. It is not the storage engine itself. - **`StagingWorkspaceRegistry` and `StagingWorkspace`** — own renewable temporary RootSet generations for multi-step application construction. `WorkspaceCompoundAdmission` and `WorkspaceCompoundScope` serialize an - explicitly bounded sequence of provisional page and bundle batches through - one operation-owned persistence view, then install the union of prior and new - targets in one exact generation. Existing workspace methods retain each - result independently and remain the boundary when a handle leaves private - construction code before later writes begin. + explicitly bounded sequence of provisional asset, page, and bundle batches + through one operation-owned persistence view. Compound calls retain all new + targets by default or canonically validate an exact nonempty selection of + handles staged by that call, then install those selected roots together with + every prior workspace target in one exact generation. Existing workspace + methods retain each result independently and remain the boundary when a + handle leaves private construction code before later writes begin. - **`RetentionService` and `PublicationService`** — validate complete handle graphs, then either retain them in a RootSet with generation-scoped evidence diff --git a/BEARING.md b/BEARING.md index 95ce37f6..6fb01ee1 100644 --- a/BEARING.md +++ b/BEARING.md @@ -171,7 +171,12 @@ These were the active tensions from the previous bearing. All resolved. ## Next Horizon With v6.5.9 shipped, active work is tracked in GitHub Issues and Milestones. -Repo docs hold design and evidence records, not the active queue. +Repo docs hold design and evidence records, not the active queue. The immediate +v6.5.10 goalpost completes compound admission with bounded asset waves and +exact newly staged terminal-root selection: + +- [#127](https://github.com/git-stunts/git-cas/issues/127) +- [0061-compound-workspace-assets](./docs/design/0061-compound-workspace-assets/compound-workspace-assets.md) The completed release design is [0060-compound-workspace-admission](./docs/design/0060-compound-workspace-admission/compound-workspace-admission.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c13b703..4b2a792b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Compound asset admission and exact terminal roots** - + `workspace.batch()` now exposes bounded `scope.assets.putBatch()` alongside + page and ordered-bundle waves. An optional synchronous `retain(value)` + selector can retain a nonempty, canonical, deduplicated subset of handles + staged by that exact call while preserving every prior workspace root. + Malformed, asynchronous, empty, oversized, lookalike, and + valid-but-unstaged selections fail before ref movement. Omitting the selector + preserves v6.5.9 retain-all behavior. + +### Performance + +- **One admission for payload-to-terminal graphs** - replay and provenance + assets can share the compound persistence scope and final workspace + installation with their support bundles, descriptor, and terminal bundle. + In the downstream controlled 65-node/65-patch git-warp prototype, cold Git + commands fell from 139 to 50 and median wall time from 2.823 s to 1.329 s; + incremental commands fell from 149 to 60 and median wall time from 3.040 s to + 1.387 s. Semantic fingerprints, cache posture, replay counts, storage + formats, handles, and the 25-command warm read path remained unchanged. + ## [6.5.9] — 2026-08-24 ### Added diff --git a/GUIDE.md b/GUIDE.md index 8e01beb4..d25930c4 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -207,25 +207,30 @@ intermediate handles inside one compound staging-workspace operation: ```js const admitted = await workspace.batch({ - maxOperations: 3, + maxOperations: 4, operation: async (scope) => { + const assets = await scope.assets.putBatch({ assets: assetRequests }); const pages = await scope.pages.putBatch({ pages: pageRequests }); const leaves = await scope.bundles.putOrderedBatch({ - bundles: leafRequests(pages), + bundles: leafRequests(assets, pages), }); return (await scope.bundles.putOrderedBatch({ bundles: [rootRequest(leaves)], }))[0]; }, + retain: (terminal) => [terminal], }); ``` `admitted.value` becomes caller-visible only with `admitted.retention`, after -one exact workspace generation anchors every staged handle. The default -operation ceiling is 64 and the hard ceiling is 1,024; each scope call also -preserves its ordinary page or bundle batch bounds. Use the existing -independently retained workspace methods when intermediate handles must leave -the private callback. +one exact workspace generation anchors the selected terminal plus every root +retained before the compound call. Omit `retain` to preserve the retain-all +behavior. A selector must synchronously return at least one canonical handle +staged by that exact callback; it cannot import another operation's handle or +remove a prior workspace root. The default operation ceiling is 64 and the hard +ceiling is 1,024; each scope call also preserves its ordinary asset, page, or +bundle batch bounds. Use the existing independently retained workspace methods +when intermediate handles must leave the private callback. Repeated `pages.get()` calls reuse immutable payload reads within the store's bounded page cache. The defaults retain at most 128 payloads and 8 MiB; use diff --git a/README.md b/README.md index e561e223..fe5bbd78 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,9 @@ Unlike traditional LFS which moves files to external servers, `git-cas` treats t blob reads at or below a fixed 10 MiB ceiling use one bounded session read; larger payloads retain the genuine one-shot streaming path. Explicitly bounded page, asset, and ordered-bundle batches pipeline independent Git - writes. `workspace.batch()` can compose dependent page and bundle waves in - one private persistence scope and retain their union under one exact final + writes. `workspace.batch()` can compose dependent asset, page, and bundle + waves in one private persistence scope and either retain their union or an + explicitly selected set of newly staged terminal roots under one exact final generation without changing content identity. - **Key Lifecycle**: Envelope encryption separates DEKs from KEKs. Rotate passphrases across an entire vault without re-encrypting data blobs. Privacy mode HMAC-hashes slug names to prevent metadata discovery. - **Runtime-Adaptive**: A single core supports Node.js 22+, Bun, and Deno through a strict hexagonal port architecture with runtime-specific crypto adapters. @@ -158,7 +159,8 @@ Core capabilities: compare-and-swap refs, and immutable lifecycle evidence. - **Scoped staging workspaces**: `workspaces.open()` mirrors application writes behind one renewable temporary RootSet, supports one-generation bounded - asset/page/bundle batches plus compound dependent page/bundle admission, + asset/page/bundle batches plus compound dependent asset/page/bundle admission + with exact newly staged terminal-root selection, returns only after each public result is anchored, promotes destination-first, and exposes bounded age, expiry, logical-content, and direct-root diagnostics with opaque cleanup pagination. diff --git a/ROADMAP.md b/ROADMAP.md index 0ed1fe0a..53cd7d81 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -47,6 +47,7 @@ GitHub wins and this file should be corrected. | [`v6.5.7`](https://github.com/git-stunts/git-cas/milestone/17) | Bounded session-backed small payload stream reads | [#115](https://github.com/git-stunts/git-cas/issues/115) | | [`v6.5.8`](https://github.com/git-stunts/git-cas/milestone/18) | Bounded application write and retention waves | [#119](https://github.com/git-stunts/git-cas/issues/119) | | [`v6.5.9`](https://github.com/git-stunts/git-cas/milestone/19) | Compound staging-workspace admission | [#123](https://github.com/git-stunts/git-cas/issues/123) | +| [`v6.5.10`](https://github.com/git-stunts/git-cas/milestone/20) | Compound workspace assets and exact roots | [#127](https://github.com/git-stunts/git-cas/issues/127) | | [`v6.6.0`](https://github.com/git-stunts/git-cas/milestone/9) | Operator TUI and agent automation follow-through | [#39](https://github.com/git-stunts/git-cas/issues/39), [#40](https://github.com/git-stunts/git-cas/issues/40) | | [`v6.7.0`](https://github.com/git-stunts/git-cas/milestone/10) | Browser and edge read-path exploration | [#41](https://github.com/git-stunts/git-cas/issues/41) | | [`v7.0.0`](https://github.com/git-stunts/git-cas/milestone/6) | Protocol break only if audit requires it | [#42](https://github.com/git-stunts/git-cas/issues/42), only when justified | @@ -61,6 +62,14 @@ Its GitHub goalpost issue, [#123](https://github.com/git-stunts/git-cas/issues/123), owns the release evidence. The design doc is the durable contract; GitHub records completion. +The active design record is: + +- [0061-compound-workspace-assets](./docs/design/0061-compound-workspace-assets/compound-workspace-assets.md) + +Its GitHub goalpost issue is +[#127](https://github.com/git-stunts/git-cas/issues/127) under the +[`v6.5.10` milestone](https://github.com/git-stunts/git-cas/milestone/20). + The `v6.2.0` slice record is: - [#54 Opaque asset handles and retention witnesses](https://github.com/git-stunts/git-cas/issues/54) diff --git a/STATUS.md b/STATUS.md index 7a1de6b5..b676c49d 100644 --- a/STATUS.md +++ b/STATUS.md @@ -182,6 +182,10 @@ [#123 v6.5.9: Compound staging-workspace admission](https://github.com/git-stunts/git-cas/issues/123) under the [`v6.5.9` milestone](https://github.com/git-stunts/git-cas/milestone/19). +- Active release goalpost: + [#127 v6.5.10: Compound workspace assets and exact roots](https://github.com/git-stunts/git-cas/issues/127) + under the + [`v6.5.10` milestone](https://github.com/git-stunts/git-cas/milestone/20). - Current queued release goalposts are [#39 v6.6.0: Operator TUI](https://github.com/git-stunts/git-cas/issues/39) and @@ -190,6 +194,8 @@ [`v6.6.0`](https://github.com/git-stunts/git-cas/milestone/9). - The latest landed design record is [0060-compound-workspace-admission](./docs/design/0060-compound-workspace-admission/compound-workspace-admission.md). +- The active design record is + [0061-compound-workspace-assets](./docs/design/0061-compound-workspace-assets/compound-workspace-assets.md). ## Read Next diff --git a/docs/design/0061-compound-workspace-assets/compound-workspace-assets.md b/docs/design/0061-compound-workspace-assets/compound-workspace-assets.md new file mode 100644 index 00000000..8ad79787 --- /dev/null +++ b/docs/design/0061-compound-workspace-assets/compound-workspace-assets.md @@ -0,0 +1,503 @@ +--- +title: 'PERF-0061 - Compound Workspace Assets and Exact Roots' +cycle: '0061' +task_id: 'compound-workspace-assets' +legend: 'PERF' +release_home: 'v6.5.10' +issue: 'https://github.com/git-stunts/git-cas/issues/127' +goalpost_issue: 'https://github.com/git-stunts/git-cas/issues/127' +tracker_source: 'github' +status: 'active' +base_commit: '6d5a43e2853f61b3c12d5000e81ef7832c00b8d2' +owners: + - '@git-stunts' +sponsors: + human: 'James' + agent: 'Codex' +blocking_issues: [] +supersedes: [] +superseded_by: null +created: '2026-08-24' +updated: '2026-08-24' +--- + +# PERF-0061 - Compound Workspace Assets and Exact Roots + +## Linked Issue + +- [#127 - Complete compound workspace admission for assets and exact roots](https://github.com/git-stunts/git-cas/issues/127) + +## Linked Tracker + +- Milestone: [`v6.5.10`](https://github.com/git-stunts/git-cas/milestone/20) +- Goalpost issue: [#127](https://github.com/git-stunts/git-cas/issues/127) +- Downstream consumer: [`git-warp#852`](https://github.com/git-stunts/git-warp/pull/852) + +## Design Type + +This design is primarily: + +- [x] Runtime/API +- [x] Storage/substrate +- [x] Migration/release +- [ ] CLI/operator +- [x] Docs/public guidance +- [ ] TUI/visual surface +- [x] Test/tooling + +## Decision Summary + +Complete the bounded `StagingWorkspace.batch()` surface introduced in v6.5.9 +by admitting asset batches through the same private persistence scope as page +and ordered-bundle waves. Add an optional synchronous retention selector over +the callback result so callers can anchor only explicitly selected newly staged +terminal roots while preserving all roots retained before the compound call. +Every selected input is parsed as a canonical application handle and must name +a handle staged by that exact compound admission. Existing methods, object +bytes, handles, ref layout, and workspace formats remain unchanged. + +## Sponsored Human + +An application operator wants one materialization graph admission to include +its replay and provenance payloads, support bundles, descriptor, and terminal +bundle so Git process and ref-publication costs do not scale with construction +depth, without weakening pruning safety or retaining every intermediate as a +top-level workspace root. + +## Sponsored Agent + +An agent needs a typed, bounded asset operation and exact selected-root evidence +so it can explain which terminal result became authoritative without inferring +transitive reachability, accepting string-like impostors, or receiving raw Git +session authority. + +## Hill + +By the end of this cycle, a caller can stage bounded assets, pages, and bundles +in dependency order inside one compound callback, select terminal handles from +that exact callback, and receive one exact workspace generation. Unit and real +SHA-1/SHA-256 Git tests prove identity, ordering, selection validation, failure +containment, pruning safety, and deterministic resource closure. + +## Current Truth + +- v6.5.9 compound admission owns one persistence scope and one final + installation, but its scope exposes only page and ordered-bundle batches. + Asset batches necessarily open a separate write scope and workspace + installation. [cite: `src/domain/services/WorkspaceCompoundScope.js@6d5a43e2853f61b3c12d5000e81ef7832c00b8d2`] +- `AssetService.putBatch()` already has bounded input count, object count, byte + count, concurrency, ordered output, failure aggregation, and write-scope + ownership. Its internal batch path can be reused without changing asset + identity. [cite: `src/domain/services/AssetService.js@6d5a43e2853f61b3c12d5000e81ef7832c00b8d2`] +- Successful compound admission currently retains every newly staged page and + bundle as a direct workspace target, even when one terminal bundle already + reaches all support transitively. [cite: `src/domain/services/WorkspaceCompoundAdmission.js@6d5a43e2853f61b3c12d5000e81ef7832c00b8d2`] +- A controlled downstream git-warp corpus with 65 nodes, 65 base patches, and + five incremental patches measured 139 cold and 149 incremental Git commands + before compound adoption. The asset-inclusive prototype measured 50 and 60 + respectively with equal semantic fingerprints, correct replay counts, and + unchanged warm-path command count. These are downstream workload results, + not universal git-cas latency guarantees. + +## Problem + +The remaining asset boundary forces a content-addressed graph to leave the +compound scope before it can build bundles referring to asset OIDs. That costs +extra persistence scopes and workspace generations. Retaining every +intermediate staged handle also bloats the direct root set even when a terminal +root already owns the complete transitive graph. A selector that accepts +arbitrary string-like values would be unsafe, while a selector that can name +old or globally staged handles would silently broaden the compound operation's +authority. + +## Scope + +This cycle includes: + +- `scope.assets.putBatch()` with the existing public asset batch options and + bounds; +- an internal asset batch path that joins an existing persistence write scope; +- optional synchronous `retain(value)` selection of newly staged application + handles; +- canonical parsing, exact staged-membership validation, stable deduplication, + and frozen selected roots; +- preservation of every workspace target retained before the compound call; +- default retain-all behavior for v6.5.9 callers; +- unit, public declaration, SHA-1/SHA-256 Git, prune, failure, and lifecycle + proof; +- public documentation, architecture, changelog, and release evidence. + +## Non-Goals + +This cycle does not include: + +- a transaction across workspaces, refs, repositories, or publications; +- arbitrary singleton operations in a compound scope; +- selecting a handle not staged by the current compound call; +- dropping roots retained before the current compound call; +- changing asset encryption, chunking, manifests, handles, refs, or workspace + descriptors; +- a git-warp graph or trie abstraction in git-cas; +- elapsed-time guarantees or a migration. + +## Runtime / API Contract + +```ts +const admitted = await workspace.batch({ + maxOperations: 3, + operation: async (scope) => { + const [asset] = await scope.assets.putBatch({ assets: [assetOptions] }); + const [support] = await scope.bundles.putOrderedBatch({ + bundles: [{ members: [['payload', asset]] }], + }); + const [terminal] = await scope.bundles.putOrderedBatch({ + bundles: [{ members: [['support', support]] }], + }); + return terminal; + }, + retain: (terminal) => [terminal], +}); +``` + +Contract laws: + +1. `scope.assets.putBatch()` consumes one compound operation and preserves the + existing asset batch's item, byte, object, concurrency, order, and error + laws. +2. Asset, page, and bundle scope calls share one operation-owned persistence + scope and retain invocation-order serialization. +3. Omitting `retain` preserves v6.5.9 retain-all behavior. +4. `retain` is called exactly once, after the callback and staged operations + succeed but before installation. It must synchronously return an array. +5. Every selector item is parsed as `ApplicationHandleInput`, must equal a + handle staged by that exact compound call, and is deduplicated by canonical + handle string while preserving first-selected order. Selector input count + cannot exceed the number of staged artifacts. +6. Selected newly staged targets are unioned with targets retained before the + compound call. The selector cannot remove those prior targets. +7. A malformed selector, non-array result, unstaged handle, callback failure, + staged failure, operation overflow, or final installation failure produces + no admitted value and does not move the workspace generation. +8. A supplied selector must choose at least one newly staged handle. An empty + selection rejects before installation rather than claiming retention for a + graph with no newly admitted terminal. +9. Intermediate immutable Git objects may remain unreachable after failure and + are reclaimable by normal Git maintenance; they are not admitted state. + +## User Experience / Product Shape + +There is no rendered interface. The user-visible surface is the package API, +TypeScript declarations, typed errors, docs, changelog, and machine-readable +witness evidence. + +## Data / State Model + +| State | Source of truth | Derived state | Invalid states | Reset behavior | Serialization | Determinism assumptions | +| ------------------ | ----------------------------------------------------- | ----------------------------- | ---------------------------------------------------- | ------------------------------------------- | ----------------------------------- | ------------------------------------------- | +| Newly staged graph | immutable Git objects plus staged-target evidence | ordered compound ledger | missing handle evidence or failed wave | unreachable objects are reclaimable | unchanged asset/page/bundle objects | existing services define identity and order | +| Selected roots | callback result plus canonical selector inputs | deduplicated staged artifacts | malformed, asynchronous, or unstaged selector output | whole admission refuses | none before installation | first selected canonical order wins | +| Prior targets | current workspace generation and in-memory target map | installation union | selector attempts implicit removal | preserved until explicit checkpoint/release | existing RootSet entries | prior generation is authoritative | +| Admitted result | checked workspace ref generation | witnesses and callback value | value visible without selected-root retention | caller promotes, checkpoints, or releases | unchanged workspace descriptor | one checked update defines admission | + +## Architecture / Anti-SLUDGE Posture + +| Concern | Decision | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Domain changes | Extend the existing compound coordinator and scope; do not add consumer vocabulary. | +| Port changes | Add only the package-level asset batch and selector shapes. | +| Adapter changes | None beyond reuse of the existing persistence write scope. | +| Boundary validation | Canonically parse every selected input and require exact membership in the operation ledger. | +| Runtime-backed nouns introduced | None; this completes the existing compound-admission noun. | +| Expected failure representation | Preserve existing `CasError` codes and original storage failures. | +| Banned shortcuts avoided | No raw Git access, string-like handle trust, nested scope, unbounded queue, schema fork, or consumer-specific topology. | + +## Cost / Residency Posture + +| Surface | Current cost | Target cost | Limit/budget | Failure mode | +| ---------------------- | ------------------------------------------------ | ------------------------------------------------- | ------------------------------------------------ | ------------------------------- | +| Asset-to-bundle graph | separate asset scope/install plus compound graph | one persistence scope and one install | existing asset and compound ceilings | whole admission refuses | +| Direct workspace roots | every staged compound artifact | prior targets plus selected new roots | existing workspace target maximum | typed retention failure | +| Asset residency | bounded by public asset batch | unchanged | existing assets/objects/bytes/concurrency limits | existing batch failure evidence | +| Selector residency | unavailable | linear in requested selections and staged targets | bounded by staged targets | invalid-options refusal | + +## Determinism / Replay / Causality + +- Grouping does not change asset, page, bundle, or terminal object identity. +- Scope call invocation order, not promise settlement timing, defines the write + sequence. +- Selection order is caller-authored and deduplicated by the first canonical + occurrence. +- The checked workspace generation witnesses retention now; it does not claim + that intermediate objects were separately admitted. +- Equal handle bytes do not authorize selection unless that handle appears in + the current operation's staged ledger. + +## Git Substrate Impact + +| Substrate area | Impact | +| ----------------------- | --------------------------------------------------------------------------------------------------- | +| refs | Existing workspace ref moves once for the complete selected graph. | +| commits | One existing parentless RootSet generation; no encoding change. | +| trees/blobs | Existing asset manifests/chunks, pages, bundles, descriptors, and RootSet objects remain unchanged. | +| object ids | Existing service outputs must remain byte-identical. | +| tag/release behavior | Publish as v6.5.10 after merge and release verification. | +| migration compatibility | Existing repositories and workspaces open in place; no migration. | + +## Compatibility / Migration Posture + +| Concern | Decision | +| -------------------------- | -------------------------------------------------------------------------------------------- | +| Public API compatibility | Additive asset scope method and optional selector; existing calls retain all staged targets. | +| Package export changes | Type declarations expand existing compound interfaces only. | +| Storage/read compatibility | No serialized shape, object identity, reader, namespace, or ref layout changes. | +| Legacy behavior retained | All singleton, independently retained batch, and v6.5.9 compound calls remain valid. | +| Deprecation behavior | None. | +| Migration path | None required. | +| Release note impact | Explicitly state migration-free physical admission optimization. | + +## Error Contract + +| Failure | Error/result | Caller recovery | Test | +| -------------------------------------- | ------------------------------------- | ----------------------------------------- | ------------------------- | +| Missing asset dependency | `INVALID_OPTIONS` during construction | supply a complete service set | dependency test | +| Invalid asset request | existing asset batch error | correct the request or split bounds | scope failure test | +| Non-function selector | `INVALID_OPTIONS` | omit it or supply a function | selector table | +| Non-array/asynchronous selector output | `INVALID_OPTIONS` | return a synchronous array | selector table | +| Malformed or unstaged handle | `INVALID_OPTIONS` | select a handle returned by this callback | adversarial selector test | +| Final exact retention failure | `WORKSPACE_RETENTION_FAILED` | inspect posture and retry | no-partial-result test | + +## Security / Trust / Redaction Posture + +- trust boundary: the callback receives only bounded semantic operations; +- authority or capability checked: exact workspace expected-head mutation; +- secret-bearing values: payload bytes remain inside existing asset services; +- redaction behavior: errors name operation and canonical handle where safe, + never payload content or encryption keys; +- log/report behavior: evidence records counts, digests, versions, and object + formats without payloads or machine-local paths; +- abuse or replay concern: selector authority is restricted to exact staged + membership and cannot import a globally known handle. + +## Lower Modes + +Public declarations, plain-text documentation, structured errors, and JSON +witness data expose the same operation, selection, identity, and retention +facts. No visual-only mode exists. + +## Accessibility Posture + +Docs and witness artifacts use linear reading order and textual labels rather +than color or layout. The package API requires no visual interaction. + +## User-Facing Text / Directionality + +English API docs, error messages, changelog, and witness labels change. They use +logical ordering words such as prior, staged, selected, and terminal; no screen +direction or visual-only distinction is introduced. + +## Agent Inspectability / Explainability Posture + +An agent can inspect the operation count, staged handles, selected retained +handles, exact generation, command/session census, semantic digest, replay +count, object format, and post-close session count without parsing timing prose +or inferring transitive roots. + +## Linked Invariants + +- every handle visible after successful workspace admission is anchored; +- only callback-private provisional handles may precede retention; +- asset/page/bundle batch bounds and order remain intact; +- selected roots belong to the exact current compound operation; +- prior retained targets survive selection; +- failure cannot expose a partial admitted result or move the prior generation; +- SHA-1 and SHA-256 object identity remains Git-authored; +- all child processes and queued operations settle before outer completion; +- release order remains Plumbing, git-cas, git-warp, then Think. + +## Design Alternatives Considered + +### Keep assets outside compound admission + +Pros: + +- no API change. + +Cons: + +- repeated write scopes and workspace installations remain the largest measured + downstream construction boundary. + +### Retain every compound artifact + +Pros: + +- current v6.5.9 behavior is simple and safe. + +Cons: + +- direct root-set size reflects construction intermediates rather than the + terminal graph; later promotion still pays for redundant top-level roots. + +### Permit arbitrary handles in the selector + +Pros: + +- could combine existing and new content in one call. + +Cons: + +- silently broadens authority, requires global resolution, and obscures which + objects this exact operation produced. + +### Add bounded asset waves and exact staged-root selection + +Pros: + +- closes the measured boundary while preserving the existing safety model; +- stays generic, additive, bounded, and storage-compatible; +- makes terminal-root authority explicit and inspectable. + +Cons: + +- adds selector failure laws and one more scoped service dependency. + +## Decision + +Add bounded asset waves and exact staged-root selection to the existing +compound admission. Keep the selector synchronous, canonical, membership- +checked, and unable to remove prior roots. Preserve retain-all as the default. + +## Proof Surface + +The implementation must be proven through: + +- actual surface under test: `StagingWorkspace.batch()` and the published + package API against unit fakes and real SHA-1/SHA-256 repositories; +- first RED test: `scope.assets.putBatch()` is unavailable on v6.5.9; +- required witness: asset-to-terminal graph identity, one checked ref update, + selected direct roots, transitive readback after immediate prune, no active + object sessions, and downstream command/semantic comparison; +- non-acceptable proof: docs-only tests, elapsed time alone, arbitrary + string-like selectors, or lower counts obtained by bypassing retention. + +Named mutation calibration: + +1. Asset batch opens its own nested write scope: scope identity/session test + fails. +2. Selector accepts a lookalike object: canonical-input adversarial test fails. +3. Selector names a valid but unstaged handle: membership test fails. +4. Selector removes prior roots: prior-target test fails. +5. Selector is ignored: direct-root cardinality test fails. +6. Asset failure still installs bundles: no-generation test fails. +7. One session survives outer settlement: lifecycle witness fails. + +## Implementation Slices + +1. Add RED scope, selector, declaration, and failure tests. +2. Expose the asset internal scoped batch path and join it to the compound + coordinator. +3. Add canonical exact-root selection and prior-root preservation. +4. Extend SHA-1/SHA-256 immediate-prune and identity integration proof. +5. Update public docs, architecture, changelog, design witness, and release + records; run full gates. +6. Publish v6.5.10, install the registry artifact in git-warp, and rerun the + exact downstream corpus. + +## Tests To Write First + +- [ ] Asset, page, and bundle waves share the supplied persistence scope and + preserve invocation order. +- [ ] The selector retains one terminal staged handle and deduplicates repeated + canonical inputs. +- [ ] Non-function, non-array, promise, empty, oversized, malformed, lookalike, + and unstaged selector results fail before ref movement. +- [ ] Prior retained workspace targets survive exact new-root selection. +- [ ] Asset failure poisons queued dependent work and emits no generation. +- [ ] Public TypeScript declarations accept the new method and selector. +- [ ] SHA-1 and SHA-256 immediate-prune tests prove terminal transitive reachability + with only selected direct roots. + +## Acceptance Criteria + +The work is done when: + +- [ ] One bounded asset/page/bundle graph produces one exact workspace + generation. +- [ ] Existing asset limits, ordered handles, and errors are preserved. +- [ ] Selector validation proves canonical exact staged membership and stable + deduplication. +- [ ] Failure and lifecycle tests prove no partial admitted result. +- [ ] Real Git proves byte-identical handles and transitive prune safety in + SHA-1 and SHA-256 repositories. +- [ ] Existing v6.5.9 calls and all persisted repositories remain compatible + without migration. +- [ ] Public docs, architecture, changelog, issue, PR, CI, and local validation + are complete. +- [ ] v6.5.10 is publicly installable before downstream results are claimed as + release behavior. + +## Validation Plan + +```sh +npx vitest run test/unit/domain/services/WorkspaceCompoundScope.test.js +npx vitest run test/unit/domain/services/StagingWorkspace.compound.test.js +npm test +npx eslint . +npm run test:integration:node +npm run test:integration:bun +npm run test:integration:deno +npm run release:verify +``` + +## Playback / Witness + +A reviewer can inspect the committed JSON witness for object formats, staged +and selected handles, direct roots, operation/generation counts, Git child +census, semantic fingerprint equality, replay evidence, and closed sessions. +The release-candidate witness binds the exact commit, package tarball, lockfile, +and validation commands. + +## Risks + +Known risks: + +- exact selection accidentally weakens prior-root retention; +- asset services accidentally open a nested scope; +- selector coercion accepts non-canonical lookalikes; +- direct-root reduction hides a missing transitive bundle edge; +- timing noise is mistaken for deterministic evidence. + +Mitigations: + +- adversarial selectors, injected persistence spies, prior-root checks, real-Git + prune/readback, identity comparisons, command topology, and semantic digests; +- treat wall time as supporting evidence only. + +## Follow-On Debt + +None currently. Any deferred reusable trie package or broader transaction +surface requires a separate GitHub issue and an independent consumer. + +## Tracker Disposition + +| Issue | Role | Expected disposition | +| -------------------------------------------------------- | ---------------- | -------------------------------- | +| [#127](https://github.com/git-stunts/git-cas/issues/127) | primary goalpost | close after publication evidence | + +## Done Does Not Mean + +When this lands, it does not prove: + +- cross-workspace or cross-ref atomicity; +- that every workload benefits equally; +- that elapsed time is deterministic; +- that git-cas owns consumer graph, trie, entity, or occurrence semantics; +- that any migration is required. + +## Retrospective + +Fill this in after implementation. + +PR: + +- pending diff --git a/docs/design/README.md b/docs/design/README.md index 9522ad66..20f01c64 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -11,6 +11,7 @@ process in [docs/method/process.md](../method/process.md). ## Active METHOD Cycles +- [0061-compound-workspace-assets - compound-workspace-assets](./0061-compound-workspace-assets/compound-workspace-assets.md) - [0054-batched-page-retention - batched-page-retention](./0054-batched-page-retention/batched-page-retention.md) - [0050-lazy-bundle-reference-reads - lazy-bundle-reference-reads](./0050-lazy-bundle-reference-reads/lazy-bundle-reference-reads.md) - [0049-scoped-staging-workspaces — scoped-staging-workspaces](./0049-scoped-staging-workspaces/scoped-staging-workspaces.md) diff --git a/index.d.ts b/index.d.ts index 18acedf0..0ce0efe0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1618,6 +1618,11 @@ export const DEFAULT_WORKSPACE_COMPOUND_OPERATIONS: 64; export const MAX_WORKSPACE_COMPOUND_OPERATIONS: 1024; export interface WorkspaceCompoundScope { + readonly assets: { + putBatch( + options: Parameters[0], + ): Promise>; + }; readonly pages: { putBatch( options: Parameters[0], @@ -1715,6 +1720,7 @@ export declare class StagingWorkspace { batch(options: { operation(scope: WorkspaceCompoundScope): T | Promise; maxOperations?: number; + retain?: (value: T) => ReadonlyArray; }): Promise>>; checkpoint(options: { handles: Iterable }): Promise; renew(): Promise; diff --git a/src/domain/services/AssetService.js b/src/domain/services/AssetService.js index ec2d5ce6..90578bf1 100644 --- a/src/domain/services/AssetService.js +++ b/src/domain/services/AssetService.js @@ -48,6 +48,11 @@ export default class AssetService { ); } + /** Internal batch path that joins an existing persistence write scope. */ + async putBatchWithPersistence(options = {}, persistence) { + return await this.#putBatch(AssetService.#batchOptions(options), persistence); + } + async #putBatch(batch, scopedPersistence) { const persistence = new BoundedWriteWavePersistence({ persistence: scopedPersistence, diff --git a/src/domain/services/StagingWorkspaceRegistry.js b/src/domain/services/StagingWorkspaceRegistry.js index 63cc1a5f..ab7032da 100644 --- a/src/domain/services/StagingWorkspaceRegistry.js +++ b/src/domain/services/StagingWorkspaceRegistry.js @@ -70,7 +70,7 @@ export default class StagingWorkspaceRegistry { this.#crypto = crypto; this.#clock = clock; this.#descriptorCodec = descriptorCodec; - this.#compound = new WorkspaceCompoundAdmission({ persistence, pages, bundles }); + this.#compound = new WorkspaceCompoundAdmission({ persistence, assets, pages, bundles }); Object.freeze(this); } diff --git a/src/domain/services/WorkspaceCompoundAdmission.js b/src/domain/services/WorkspaceCompoundAdmission.js index 81d58d66..a9575eb3 100644 --- a/src/domain/services/WorkspaceCompoundAdmission.js +++ b/src/domain/services/WorkspaceCompoundAdmission.js @@ -1,22 +1,25 @@ import createCasError from '../errors/createCasError.js'; import { ErrorCodes } from '../errors/index.js'; +import parseApplicationHandle from '../value-objects/ApplicationHandle.js'; import WorkspaceCompoundScope from './WorkspaceCompoundScope.js'; /** Executes one bounded provisional graph build before exact workspace retention. */ export default class WorkspaceCompoundAdmission { + #assets; #bundles; #pages; #persistence; - constructor({ persistence, pages, bundles }) { - WorkspaceCompoundAdmission.#assertDependencies({ persistence, pages, bundles }); + constructor({ persistence, assets, pages, bundles }) { + WorkspaceCompoundAdmission.#assertDependencies({ persistence, assets, pages, bundles }); this.#persistence = persistence; + this.#assets = assets; this.#pages = pages; this.#bundles = bundles; Object.freeze(this); } - async admit({ operation, maxOperations, install } = {}) { + async admit({ operation, maxOperations, retain, install } = {}) { if (typeof install !== 'function') { throw createCasError( 'Workspace compound admission requires an installation callback', @@ -25,20 +28,23 @@ export default class WorkspaceCompoundAdmission { } return await withWriteScope(this.#persistence, async (persistence) => { const scope = new WorkspaceCompoundScope({ + assets: this.#assets, pages: this.#pages, bundles: this.#bundles, persistence, maxOperations, }); const prepared = await scope.execute(operation); - const retention = await install(prepared.staged, persistence); + const selected = retainedArtifacts(prepared, retain); + const retention = await install(selected, persistence); return Object.freeze({ value: prepared.value, retention }); }); } - static #assertDependencies({ persistence, pages, bundles }) { + static #assertDependencies({ persistence, assets, pages, bundles }) { const missing = [ ['persistence', persistence === null || typeof persistence !== 'object'], + ['assets', assets === null || typeof assets !== 'object'], ['pages.putBatch', typeof pages?.putBatch !== 'function'], ['bundles', bundles === null || typeof bundles !== 'object'], ] @@ -54,6 +60,75 @@ export default class WorkspaceCompoundAdmission { } } +function retainedArtifacts(prepared, retain) { + if (retain === undefined) { + return prepared.staged; + } + const requested = selectedInputs(prepared, retain); + const staged = new Map(prepared.staged.map((artifact) => [artifact.handle.toString(), artifact])); + const selected = []; + const seen = new Set(); + for (const input of requested) { + const handle = selectedHandle(input); + const key = handle.toString(); + const artifact = staged.get(key); + if (artifact === undefined) { + throw createCasError( + 'Workspace compound retention selector returned an unstaged handle', + ErrorCodes.INVALID_OPTIONS, + { handle: key } + ); + } + if (!seen.has(key)) { + selected.push(artifact); + seen.add(key); + } + } + return Object.freeze(selected); +} + +function selectedInputs(prepared, retain) { + if (typeof retain !== 'function') { + throw createCasError( + 'Workspace compound retention selector must be a function', + ErrorCodes.INVALID_OPTIONS + ); + } + const requested = retain(prepared.value); + if (!Array.isArray(requested)) { + throw createCasError( + 'Workspace compound retention selector must synchronously return an array', + ErrorCodes.INVALID_OPTIONS + ); + } + if (requested.length === 0) { + throw createCasError( + 'Workspace compound retention selector must choose at least one staged handle', + ErrorCodes.INVALID_OPTIONS + ); + } + if (requested.length > prepared.staged.length) { + throw createCasError( + 'Workspace compound retention selector exceeds the staged handle count', + ErrorCodes.INVALID_OPTIONS, + { requestedCount: requested.length, stagedCount: prepared.staged.length } + ); + } + return requested; +} + +function selectedHandle(input) { + try { + return parseApplicationHandle(input); + } catch (error) { + throw createCasError( + 'Workspace compound retention selector returned an invalid handle', + ErrorCodes.INVALID_OPTIONS, + { originalError: error } + ); + } +} + async function withWriteScope(persistence, operation) { return typeof persistence.withWriteScope === 'function' ? await persistence.withWriteScope(operation) diff --git a/src/domain/services/WorkspaceCompoundScope.js b/src/domain/services/WorkspaceCompoundScope.js index 4f36b696..69bb8300 100644 --- a/src/domain/services/WorkspaceCompoundScope.js +++ b/src/domain/services/WorkspaceCompoundScope.js @@ -4,9 +4,10 @@ import { ErrorCodes } from '../errors/index.js'; export const DEFAULT_WORKSPACE_COMPOUND_OPERATIONS = 64; export const MAX_WORKSPACE_COMPOUND_OPERATIONS = 1_024; -/** Bounded provisional page and bundle writes owned by one compound admission. */ +/** Bounded provisional asset, page, and bundle writes owned by one compound admission. */ export default class WorkspaceCompoundScope { #active = true; + #assets; #abortFailure; #aborted = false; #bundles; @@ -20,13 +21,18 @@ export default class WorkspaceCompoundScope { #staged = []; #tail = Promise.resolve(); - constructor({ pages, bundles, persistence, maxOperations }) { - WorkspaceCompoundScope.#assertDependencies({ pages, bundles, persistence }); + constructor({ assets, pages, bundles, persistence, maxOperations }) { + WorkspaceCompoundScope.#assertDependencies({ assets, pages, bundles, persistence }); + this.#assets = assets; this.#pages = pages; this.#bundles = bundles; this.#persistence = persistence; this.#maxOperations = WorkspaceCompoundScope.#operationLimit(maxOperations); this.api = Object.freeze({ + assets: Object.freeze({ + putBatch: (options) => + this.#enqueue('assets.putBatch', async () => await this.#putAssets(options)), + }), pages: Object.freeze({ putBatch: (options) => this.#enqueue('pages.putBatch', async () => await this.#putPages(options)), @@ -137,6 +143,18 @@ export default class WorkspaceCompoundScope { } } + async #putAssets(options) { + if (typeof this.#assets?.putBatchWithPersistence !== 'function') { + throw createCasError( + 'Workspace compound asset batches are unavailable', + ErrorCodes.INVALID_OPTIONS, + { method: 'assets.putBatch' } + ); + } + const staged = await this.#assets.putBatchWithPersistence(options, this.#persistence); + return this.#record(staged, 'asset'); + } + async #putPages(options) { const staged = await this.#pages.putBatchWithPersistence(options, this.#persistence); return this.#record(staged, 'page'); @@ -171,8 +189,9 @@ export default class WorkspaceCompoundScope { return limit; } - static #assertDependencies({ pages, bundles, persistence }) { + static #assertDependencies({ assets, pages, bundles, persistence }) { const missing = [ + ['assets', assets === null || typeof assets !== 'object'], ['pages.putBatchWithPersistence', typeof pages?.putBatchWithPersistence !== 'function'], [ 'bundles.putOrderedBatchWithPersistence', diff --git a/test/integration/compound-workspace-admission.test.js b/test/integration/compound-workspace-admission.test.js index 5a980f5d..41bffb0b 100644 --- a/test/integration/compound-workspace-admission.test.js +++ b/test/integration/compound-workspace-admission.test.js @@ -83,27 +83,43 @@ async function proveFailureContainment(objectFormat) { async function admitGraph(workspace) { return await workspace.batch({ - maxOperations: 3, + maxOperations: 4, operation: async (scope) => await stageGraph(scope), + retain: (terminal) => [terminal], }); } async function stageGraph(scope) { + const [asset] = await scope.assets.putBatch({ + assets: [ + { + source: singleChunk(Buffer.from('compound asset payload')), + slug: 'compound-asset', + filename: 'payload.bin', + }, + ], + }); const pages = await scope.pages.putBatch({ pages: Array.from({ length: 8 }, (_, index) => ({ source: Buffer.from(`compound-page-${index}`), })), }); - const leaves = await scope.bundles.putOrderedBatch({ - bundles: pages.map((page, index) => ({ - members: [[`payload/${index}`, page]], - })), + const supports = await scope.bundles.putOrderedBatch({ + bundles: [ + { members: [['payload.bin', asset]] }, + ...pages.map((page, index) => ({ + members: [[`payload/${index}`, page]], + })), + ], }); return ( await scope.bundles.putOrderedBatch({ bundles: [ { - members: leaves.map((leaf, index) => [`leaves/${index}`, leaf]), + members: [ + ...supports.slice(1).map((leaf, index) => [`leaves/${index}`, leaf]), + ['support/assets', supports[0]], + ], }, ], }) @@ -111,32 +127,58 @@ async function stageGraph(scope) { } async function assertAdmission({ admitted, cas, counted, repo, workspace }) { - const retainedOids = admitted.retention.handles.map((handle) => handle.oid); const counts = counted.snapshot(); expect(count(counts, 'update-ref') + count(counts, 'session:update-ref')).toBe(1); expect(count(counts, 'session:fast-import')).toBe(1); expect(count(counted.activeSessions(), 'fast-import')).toBe(0); + expect(admitted.retention.handles.map((handle) => handle.toString())).toEqual([ + admitted.value.toString(), + ]); expect(git(repo, ['rev-parse', admitted.retention.ref])).toBe(admitted.retention.generation); - expect(reachableOids(repo, admitted.retention.ref)).toEqual( - expect.arrayContaining(admitted.retention.handles.map((handle) => handle.oid)) - ); + const reachableBeforeRelease = reachableOids(repo, admitted.retention.ref); + expect(reachableBeforeRelease).toEqual(expect.arrayContaining([admitted.value.oid])); git(repo, ['prune', '--expire=now']); + const assetSupport = await cas.bundles.getMemberReference({ + handle: admitted.value, + path: 'support/assets', + }); + const asset = await cas.bundles.getMemberReference({ + handle: assetSupport.handle, + path: 'payload.bin', + }); + expect( + Buffer.from(await collect(cas.assets.open({ handle: asset.handle }))).toString('utf8') + ).toBe('compound asset payload'); const firstLeaf = await cas.bundles.getMember({ handle: admitted.value, path: 'leaves/0', }); - await expect( - cas.bundles.getMember({ - handle: firstLeaf.handle, - path: 'payload/0', - }) - ).resolves.toMatchObject({ handle: admitted.retention.handles[0] }); + const firstPage = await cas.bundles.getMember({ + handle: firstLeaf.handle, + path: 'payload/0', + }); + expect(firstPage.handle.kind).toBe('page'); + expect(Buffer.from(await cas.pages.get({ handle: firstPage.handle })).toString('utf8')).toBe( + 'compound-page-0' + ); await workspace.release(); git(repo, ['reflog', 'expire', '--expire=now', '--all']); git(repo, ['gc', '--prune=now']); - expect(retainedOids.every((oid) => !objectExists(repo, oid))).toBe(true); + expect(reachableBeforeRelease.every((oid) => !objectExists(repo, oid))).toBe(true); +} + +async function collect(source) { + const chunks = []; + for await (const chunk of source) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +async function* singleChunk(bytes) { + yield bytes; } function initializeRepository(repo, objectFormat) { diff --git a/test/unit/domain/services/AssetService.batch.test.js b/test/unit/domain/services/AssetService.batch.test.js index 60ed976d..d7cd0607 100644 --- a/test/unit/domain/services/AssetService.batch.test.js +++ b/test/unit/domain/services/AssetService.batch.test.js @@ -104,6 +104,24 @@ async function* laterSource(started) { yield Buffer.from('must not start'); } +describe('AssetService caller-owned write scope', () => { + it('joins a caller-owned persistence scope without opening a nested scope', async () => { + const { assets, persistence } = fixture(); + const withWriteScope = vi.spyOn(persistence, 'withWriteScope'); + + const batch = await assets.putBatchWithPersistence( + { + assets: requests(), + ...LIMITS, + }, + persistence + ); + + expect(batch).toHaveLength(requests().length); + expect(withWriteScope).not.toHaveBeenCalled(); + }); +}); + describe('AssetService write batches', () => { it('preserves single-write handles and uses bounded persistence batches', async () => { const singlesFixture = fixture(); diff --git a/test/unit/domain/services/StagingWorkspace.compound.test.js b/test/unit/domain/services/StagingWorkspace.compound.test.js index 43c97cb0..f3be2bf9 100644 --- a/test/unit/domain/services/StagingWorkspace.compound.test.js +++ b/test/unit/domain/services/StagingWorkspace.compound.test.js @@ -10,7 +10,7 @@ import MemoryRefAdapter from '../../../helpers/MemoryRefAdapter.js'; const CLOCK = Object.freeze({ now: () => new Date('2026-08-24T17:00:00.000Z') }); -function fixture({ withWriteScope = true } = {}) { +function fixture({ withWriteScope = true, putAssets = vi.fn() } = {}) { const persistence = new MemoryPersistenceAdapter(); if (!withWriteScope) { Object.defineProperty(persistence, 'withWriteScope', { value: undefined }); @@ -35,7 +35,12 @@ function fixture({ withWriteScope = true } = {}) { const registry = new StagingWorkspaceRegistry({ persistence, ref, - assets: { put: vi.fn(), putBatch: vi.fn(), adopt: vi.fn() }, + assets: { + put: vi.fn(), + putBatch: vi.fn(), + putBatchWithPersistence: putAssets, + adopt: vi.fn(), + }, pages, bundles: services.bundles, resolveHandle, @@ -104,6 +109,98 @@ describe('StagingWorkspace compound persistence compatibility', () => { }); }); +describe('StagingWorkspace compound exact retention selection', () => { + it('retains only selected new roots while preserving prior workspace roots', async () => { + const { registry } = fixture(); + const workspace = await registry.open({ + namespace: 'git-warp/materializations', + ttlMs: 60_000, + }); + const prior = await workspace.pages.put({ source: new Uint8Array([0]) }); + + const admitted = await workspace.batch({ + operation: async (scope) => { + const pages = await scope.pages.putBatch({ + pages: [{ source: new Uint8Array([1]) }, { source: new Uint8Array([2]) }], + }); + return pages[1]; + }, + retain: (terminal) => [terminal, terminal.toString()], + }); + + expect(admitted.retention.handles.map((handle) => handle.toString())).toEqual([ + prior.handle.toString(), + admitted.value.toString(), + ]); + }); +}); + +describe('StagingWorkspace compound selector shape', () => { + it.each([ + ['a non-function', 'terminal'], + ['a non-array result', () => new Set()], + ['an asynchronous result', async (terminal) => [terminal]], + ['an empty result', () => []], + ['more inputs than staged handles', (terminal) => [terminal, terminal]], + ['a malformed handle', () => ['not-an-application-handle']], + ])('rejects %s retention selector without moving the workspace ref', async (_, retain) => { + const { ref, registry } = fixture(); + const updateRef = vi.spyOn(ref, 'updateRef'); + const workspace = await registry.open({ + namespace: 'git-warp/materializations', + ttlMs: 60_000, + }); + + await expect( + workspace.batch({ + operation: async (scope) => + (await scope.pages.putBatch({ pages: [{ source: new Uint8Array([1]) }] }))[0], + retain, + }) + ).rejects.toMatchObject({ code: 'INVALID_OPTIONS' }); + expect(updateRef).not.toHaveBeenCalled(); + }); +}); + +describe('StagingWorkspace compound selector authority', () => { + it('rejects a canonical handle that was not staged by this compound call', async () => { + const { ref, registry } = fixture(); + const updateRef = vi.spyOn(ref, 'updateRef'); + const workspace = await registry.open({ + namespace: 'git-warp/materializations', + ttlMs: 60_000, + }); + const prior = await workspace.pages.put({ source: new Uint8Array([0]) }); + + await expect( + workspace.batch({ + operation: async (scope) => + (await scope.pages.putBatch({ pages: [{ source: new Uint8Array([1]) }] }))[0], + retain: () => [prior.handle], + }) + ).rejects.toMatchObject({ code: 'INVALID_OPTIONS' }); + expect(updateRef).toHaveBeenCalledOnce(); + }); + + it('rejects a string-like handle impostor without moving the workspace ref', async () => { + const { ref, registry } = fixture(); + const updateRef = vi.spyOn(ref, 'updateRef'); + const workspace = await registry.open({ + namespace: 'git-warp/materializations', + ttlMs: 60_000, + }); + + await expect( + workspace.batch({ + operation: async (scope) => + (await scope.pages.putBatch({ pages: [{ source: new Uint8Array([1]) }] }))[0], + retain: (terminal) => [{ toString: () => terminal.toString() }], + }) + ).rejects.toMatchObject({ code: 'INVALID_OPTIONS' }); + expect(updateRef).not.toHaveBeenCalled(); + }); +}); + describe('StagingWorkspace compound retention and bounds', () => { it('retains prior workspace targets in the one compound generation', async () => { const { ref, registry } = fixture(); @@ -265,6 +362,27 @@ describe('StagingWorkspace compound failure containment', () => { }); }); +describe('StagingWorkspace compound asset failure containment', () => { + it('does not move the ref when a scoped asset batch fails', async () => { + const assetFailure = new Error('asset batch failed'); + const { ref, registry } = fixture({ + putAssets: vi.fn().mockRejectedValue(assetFailure), + }); + const updateRef = vi.spyOn(ref, 'updateRef'); + const workspace = await registry.open({ + namespace: 'git-warp/materializations', + ttlMs: 60_000, + }); + + await expect( + workspace.batch({ + operation: async (scope) => await scope.assets.putBatch({ assets: [] }), + }) + ).rejects.toBe(assetFailure); + expect(updateRef).not.toHaveBeenCalled(); + }); +}); + describe('StagingWorkspace compound retention failure', () => { it('preserves the previous generation when final retention fails', async () => { const { ref, registry } = fixture(); diff --git a/test/unit/domain/services/WorkspaceCompoundScope.test.js b/test/unit/domain/services/WorkspaceCompoundScope.test.js index a96384b5..048024bf 100644 --- a/test/unit/domain/services/WorkspaceCompoundScope.test.js +++ b/test/unit/domain/services/WorkspaceCompoundScope.test.js @@ -13,7 +13,10 @@ function ignoreRejection(promise) { void promise.catch(() => undefined); } -function fixture({ putPages, putBundles, maxOperations } = {}) { +function fixture({ putAssets, putPages, putBundles, maxOperations } = {}) { + const assets = { + putBatchWithPersistence: vi.fn(putAssets ?? (async ({ id }) => [artifact(`asset:${id}`)])), + }; const pages = { putBatchWithPersistence: vi.fn(putPages ?? (async ({ id }) => [artifact(`page:${id}`)])), }; @@ -23,14 +26,37 @@ function fixture({ putPages, putBundles, maxOperations } = {}) { ), }; const scope = new WorkspaceCompoundScope({ + assets, pages, bundles, persistence: PERSISTENCE, maxOperations, }); - return { bundles, pages, scope }; + return { assets, bundles, pages, scope }; } +describe('WorkspaceCompoundScope asset admission', () => { + it('stages bounded asset waves inside the supplied persistence scope', async () => { + const { assets, bundles, scope } = fixture(); + + const result = await scope.execute(async (api) => { + const [asset] = await api.assets.putBatch({ id: 'payload' }); + return await api.bundles.putOrderedBatch({ id: asset.toString() }); + }); + + expect(assets.putBatchWithPersistence).toHaveBeenCalledWith({ id: 'payload' }, PERSISTENCE); + expect(bundles.putOrderedBatchWithPersistence).toHaveBeenCalledWith( + { id: 'asset:payload' }, + PERSISTENCE + ); + expect(result.operationCount).toBe(2); + expect(result.staged.map((entry) => entry.handle.toString())).toEqual([ + 'asset:payload', + 'bundle:asset:payload', + ]); + }); +}); + describe('WorkspaceCompoundScope ordering', () => { it('serializes concurrently started operations by invocation order', async () => { const order = []; @@ -64,6 +90,28 @@ describe('WorkspaceCompoundScope ordering', () => { }); }); +describe('WorkspaceCompoundScope asset capability compatibility', () => { + it('keeps page waves available when the optional scoped asset hook is absent', async () => { + const { bundles, pages } = fixture(); + const compatibleScope = () => + new WorkspaceCompoundScope({ + assets: {}, + pages, + bundles, + persistence: PERSISTENCE, + }); + + const result = await compatibleScope().execute( + async (api) => await api.pages.putBatch({ id: 'still-supported' }) + ); + expect(result.staged).toHaveLength(1); + + await expect( + compatibleScope().execute(async (api) => await api.assets.putBatch({ id: 'unsupported' })) + ).rejects.toMatchObject({ code: 'INVALID_OPTIONS' }); + }); +}); + describe('WorkspaceCompoundScope operation bounds', () => { it('refuses calls after overflow without extending the bounded queue', async () => { let releaseFirst; diff --git a/test/unit/types/declaration-accuracy.test.js b/test/unit/types/declaration-accuracy.test.js index 8eb2508e..8c016a60 100644 --- a/test/unit/types/declaration-accuracy.test.js +++ b/test/unit/types/declaration-accuracy.test.js @@ -131,7 +131,10 @@ describe('Compound workspace declaration accuracy', () => { expect(declarations).toContain('export interface WorkspaceCompoundScope {'); expect(declarations).toContain('export interface WorkspaceCompoundResult {'); + expect(declarations).toContain('readonly assets: {'); + expect(declarations).toContain('Promise>;'); expect(declarations).toContain('batch(options: {'); + expect(declarations).toContain('retain?: (value: T) => ReadonlyArray;'); expect(declarations).toContain('export const DEFAULT_WORKSPACE_COMPOUND_OPERATIONS: 64;'); expect(declarations).toContain('export const MAX_WORKSPACE_COMPOUND_OPERATIONS: 1024;'); }); From 6714750620aa2310ad0279f957414be65898a66a Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 24 Aug 2026 14:21:19 -0700 Subject: [PATCH 2/2] docs: record compound workspace verification --- .../compound-workspace-assets.md | 42 +++--- .../witness/compound-workspace-assets.json | 102 +++++++++++++++ .../witness/verification.md | 123 ++++++++++++++++++ 3 files changed, 251 insertions(+), 16 deletions(-) create mode 100644 docs/design/0061-compound-workspace-assets/witness/compound-workspace-assets.json create mode 100644 docs/design/0061-compound-workspace-assets/witness/verification.md diff --git a/docs/design/0061-compound-workspace-assets/compound-workspace-assets.md b/docs/design/0061-compound-workspace-assets/compound-workspace-assets.md index 8ad79787..b143b59b 100644 --- a/docs/design/0061-compound-workspace-assets/compound-workspace-assets.md +++ b/docs/design/0061-compound-workspace-assets/compound-workspace-assets.md @@ -405,31 +405,31 @@ Named mutation calibration: ## Tests To Write First -- [ ] Asset, page, and bundle waves share the supplied persistence scope and +- [x] Asset, page, and bundle waves share the supplied persistence scope and preserve invocation order. -- [ ] The selector retains one terminal staged handle and deduplicates repeated +- [x] The selector retains one terminal staged handle and deduplicates repeated canonical inputs. -- [ ] Non-function, non-array, promise, empty, oversized, malformed, lookalike, +- [x] Non-function, non-array, promise, empty, oversized, malformed, lookalike, and unstaged selector results fail before ref movement. -- [ ] Prior retained workspace targets survive exact new-root selection. -- [ ] Asset failure poisons queued dependent work and emits no generation. -- [ ] Public TypeScript declarations accept the new method and selector. -- [ ] SHA-1 and SHA-256 immediate-prune tests prove terminal transitive reachability +- [x] Prior retained workspace targets survive exact new-root selection. +- [x] Asset failure poisons queued dependent work and emits no generation. +- [x] Public TypeScript declarations accept the new method and selector. +- [x] SHA-1 and SHA-256 immediate-prune tests prove terminal transitive reachability with only selected direct roots. ## Acceptance Criteria The work is done when: -- [ ] One bounded asset/page/bundle graph produces one exact workspace +- [x] One bounded asset/page/bundle graph produces one exact workspace generation. -- [ ] Existing asset limits, ordered handles, and errors are preserved. -- [ ] Selector validation proves canonical exact staged membership and stable +- [x] Existing asset limits, ordered handles, and errors are preserved. +- [x] Selector validation proves canonical exact staged membership and stable deduplication. -- [ ] Failure and lifecycle tests prove no partial admitted result. -- [ ] Real Git proves byte-identical handles and transitive prune safety in +- [x] Failure and lifecycle tests prove no partial admitted result. +- [x] Real Git proves byte-identical handles and transitive prune safety in SHA-1 and SHA-256 repositories. -- [ ] Existing v6.5.9 calls and all persisted repositories remain compatible +- [x] Existing v6.5.9 calls and all persisted repositories remain compatible without migration. - [ ] Public docs, architecture, changelog, issue, PR, CI, and local validation are complete. @@ -496,8 +496,18 @@ When this lands, it does not prove: ## Retrospective -Fill this in after implementation. +Implementation PR: -PR: +- [#128 - Complete compound workspace asset admission](https://github.com/git-stunts/git-cas/pull/128) -- pending +The implementation checkpoint is +`e663754bf221784f0e5856a41fe071bebfa5befb`. Its complete release method passed +14/14 stages with 7,186 observed tests. Self-audit found and closed one +boundedness hole before review: selector input count is now capped by the exact +staged-artifact count, preventing duplicate input scanning from exceeding the +admission's own evidence ledger. + +The controlled git-warp prototype reduced cold Git commands from 139 to 50 and +incremental commands from 149 to 60 with equal semantic fingerprints and replay +counts. That result remains provisional until it is repeated against the +public v6.5.10 registry artifact. diff --git a/docs/design/0061-compound-workspace-assets/witness/compound-workspace-assets.json b/docs/design/0061-compound-workspace-assets/witness/compound-workspace-assets.json new file mode 100644 index 00000000..b83c4e0f --- /dev/null +++ b/docs/design/0061-compound-workspace-assets/witness/compound-workspace-assets.json @@ -0,0 +1,102 @@ +{ + "schema": "git-cas.compound-workspace-assets/v1", + "generatedAt": "2026-08-24T21:19:41Z", + "implementation": { + "commit": "e663754bf221784f0e5856a41fe071bebfa5befb", + "baseCommit": "6d5a43e2853f61b3c12d5000e81ef7832c00b8d2", + "pullRequest": "https://github.com/git-stunts/git-cas/pull/128", + "worktreeCleanBeforeVerification": true, + "plumbingVersion": "3.3.0" + }, + "environment": { + "node": "v26.0.0", + "git": "git version 2.50.1 (Apple Git-155)", + "platform": "darwin", + "architecture": "arm64" + }, + "red": { + "baseVersion": "6.5.9", + "expectedFocusedFailures": 10, + "provedMissing": [ + "compound asset capability", + "caller-owned asset persistence scope", + "exact selected-root retention", + "selector shape and authority validation", + "public declaration surface" + ] + }, + "verification": { + "command": "pnpm run release:verify", + "stepsPassed": 14, + "stepsTotal": 14, + "observedTests": 7186, + "steps": { + "lint": "PASS", + "unitNode": { "status": "PASS", "passed": 2192, "skipped": 2 }, + "unitBun": { "status": "PASS", "passed": 2191, "skipped": 3 }, + "unitDeno": { "status": "PASS", "passed": 2182, "skipped": 12 }, + "publicTypeCompatibility": "PASS", + "integrationNode": { "status": "PASS", "passed": 207 }, + "integrationBun": { "status": "PASS", "passed": 207 }, + "integrationDeno": { "status": "PASS", "passed": 207 }, + "examples": "PASS", + "buildMetadataStamp": "PASS", + "npmPackDryRun": "PASS", + "jsrPublishDryRun": "PASS" + } + }, + "realGitContract": { + "objectFormats": ["sha1", "sha256"], + "compoundCases": 4, + "checkedRefUpdatesPerAdmission": 1, + "fastImportSessionsPerAdmission": 1, + "activeFastImportSessionsAfterSettlement": 0, + "selectedDirectRootCount": 1, + "immediatePruneTransitiveAssetReadback": true, + "immediatePruneTransitivePageReadback": true, + "releaseAndGcReclaimsReachableGraph": true + }, + "downstreamPrototype": { + "authority": "pre-release prototype; rerun against the public registry artifact is required", + "gitWarpCommit": "0ab91f34f5bd0116ef8387e8f487b677c67d9eaa", + "gitWarpPullRequest": "https://github.com/git-stunts/git-warp/pull/852", + "gitCasControlVersion": "6.5.9", + "corpus": { + "baseNodes": 65, + "basePatches": 65, + "incrementalPatches": 5, + "measuredRuns": 3, + "warmupRuns": 1 + }, + "control": { + "generatedAt": "2026-08-24T19:35:19.385Z", + "cold": { "gitCommands": 139, "cpuTotalMs": 545.476, "wallMs": 2822.893416 }, + "incremental": { "gitCommands": 149, "cpuTotalMs": 555.478, "wallMs": 3039.535959 }, + "warm": { "gitCommands": 25, "cpuTotalMs": 166.882, "wallMs": 548.19075 } + }, + "compoundAssets": { + "generatedAt": "2026-08-24T20:45:47.017Z", + "cold": { "gitCommands": 50, "cpuTotalMs": 322.103, "wallMs": 1328.603125 }, + "incremental": { "gitCommands": 60, "cpuTotalMs": 320.05, "wallMs": 1387.245 }, + "warm": { "gitCommands": 25, "cpuTotalMs": 176.325, "wallMs": 622.956333 } + }, + "reductionPercent": { + "cold": { "gitCommands": 64.029, "cpuTotalMs": 40.95, "wallMs": 52.935 }, + "incremental": { "gitCommands": 59.732, "cpuTotalMs": 42.383, "wallMs": 54.36 } + }, + "semantic": { + "coldFingerprint": "d4a3d26858bddad534c3f5d00e5ed8f7896767ae9e05e972b6005892f85e73e3", + "incrementalFingerprint": "d409bceebad0457de14e49fc34fd41af87b1d278d0eb18ff259e9b10088697ca", + "replayedPatches": { "cold": 65, "incremental": 5, "warm": 0 }, + "controlFingerprintsEqual": true, + "warmCommandPathChanged": false + } + }, + "compatibility": { + "migrationRequired": false, + "storageFormatChanged": false, + "handleIdentityChanged": false, + "refLayoutChanged": false, + "defaultRetainAllChanged": false + } +} diff --git a/docs/design/0061-compound-workspace-assets/witness/verification.md b/docs/design/0061-compound-workspace-assets/witness/verification.md new file mode 100644 index 00000000..46d21fa3 --- /dev/null +++ b/docs/design/0061-compound-workspace-assets/witness/verification.md @@ -0,0 +1,123 @@ +# Compound Workspace Assets Verification + +## Exact implementation + +- implementation commit: `e663754bf221784f0e5856a41fe071bebfa5befb` +- implementation PR: [#128](https://github.com/git-stunts/git-cas/pull/128) +- base commit: `6d5a43e2853f61b3c12d5000e81ef7832c00b8d2` +- installed Plumbing: `@git-stunts/plumbing@3.3.0` +- Node.js: `v26.0.0` +- Git: `2.50.1 (Apple Git-155)` +- host: macOS arm64 +- structured witness: + [`compound-workspace-assets.json`](./compound-workspace-assets.json) + +The implementation worktree was clean when the complete release verifier +started and after it completed. The verifier's build stamp named the same +implementation commit. + +## Red to green + +The first focused run against the v6.5.9 surface produced ten expected +failures. They proved that compound admission lacked an asset capability, the +asset service could not join a caller-owned persistence scope, exact selected +roots and adversarial selector laws were absent, and the public declarations +did not expose the new contract. + +The final focused contract passed 46/46 tests. It covers: + +- asset batches sharing the caller-owned persistence scope without nesting; +- invocation-ordered asset, page, and bundle waves; +- retain-all compatibility and exact selected-root retention; +- stable canonical deduplication and prior-root preservation; +- non-function, non-array, asynchronous, empty, oversized, malformed, + lookalike, and valid-but-unstaged selector refusal before ref movement; +- asset failure containment and public TypeScript declarations. + +## Complete release method + +```bash +pnpm run release:verify +``` + +| Gate | Result | Observed tests | +| ------------------------- | ------ | -------------: | +| Lint | PASS | - | +| Unit tests (Node) | PASS | 2,192 | +| Unit tests (Bun) | PASS | 2,191 | +| Unit tests (Deno) | PASS | 2,182 | +| Public type compatibility | PASS | - | +| Integration tests (Node) | PASS | 207 | +| Integration tests (Bun) | PASS | 207 | +| Integration tests (Deno) | PASS | 207 | +| Examples | PASS | - | +| Build metadata stamp | PASS | - | +| npm pack dry-run | PASS | - | +| JSR publish dry-run | PASS | - | +| **Method summary** | 14/14 | **7,186** | + +Runtime-defined skips account for the different unit totals. No failing test +or incomplete verifier stage was omitted from the summary. + +## Real Git safety proof + +The integration suite runs the compound graph against fresh SHA-1 and SHA-256 +repositories. Each object format proves: + +- one checked workspace ref update and one scoped fast-import session; +- no active fast-import session after the outer operation settles; +- one selected direct terminal root rather than every construction + intermediate; +- transitive asset and page readback after `git prune --expire=now`; +- graph reclamation after workspace release, reflog expiry, and immediate Git + garbage collection; +- failure containment without a partial generation. + +The selector does not turn physical batching into a cross-ref transaction. +Immutable objects written before refusal can remain unreachable until normal +Git maintenance reclaims them. + +## Downstream controlled prototype + +git-warp PR [#852](https://github.com/git-stunts/git-warp/pull/852) exercised a +65-node/65-patch corpus plus a five-patch suffix. Each scenario used one warmup +and three measured runs. The control used public git-cas v6.5.9; the optimized +run used the exact API implemented here through a local prototype. + +| Scenario | Git commands | Median CPU ms | Median wall ms | +| ----------- | -----------: | ------------: | -------------: | +| Cold before | 139 | 545.476 | 2,822.893 | +| Cold after | 50 | 322.103 | 1,328.603 | +| Incr before | 149 | 555.478 | 3,039.536 | +| Incr after | 60 | 320.050 | 1,387.245 | +| Warm before | 25 | 166.882 | 548.191 | +| Warm after | 25 | 176.325 | 622.956 | + +The cold path used 64.029% fewer Git commands, 40.950% less Node CPU, and +52.935% less wall time. The incremental path used 59.732% fewer Git commands, +42.383% less Node CPU, and 54.360% less wall time. The warm path performs no +materialization write and retained the exact 25-command topology; its elapsed +variation is host noise, not a claimed regression or improvement. + +Semantic fingerprints, node/edge/property counts, cache posture, and replay +counts were equal between control and prototype: 65 cold patches, five +incremental patches, and zero warm patches. These measurements justify the API +but are not yet registry-artifact evidence. They must be rerun after v6.5.10 is +publicly installable. + +## Compatibility boundary + +This implementation is additive and migration-free. It changes no stored +asset, page, bundle, descriptor, RootSet, handle, namespace, ref layout, or +reader. Omitting `retain` preserves v6.5.9 retain-all behavior. Existing +repositories and active workspace refs open in place without rewriting or an +authority cutover. + +## Publication boundary + +The implementation is not a published v6.5.10 release merely because this +witness and local verification are green. Publication still requires hosted +review and CI on the exact PR head, a normal reviewed merge, a separately +reviewed versioned release candidate, a signed annotated tag that peels to the +reviewed release merge, successful registry publication, and an external +consumer installation check.