Skip to content

Commit 1ac2fc8

Browse files
authored
Merge pull request #76 from git-stunts/feat/scoped-staging-workspaces
feat: add scoped staging workspaces
2 parents beb8a29 + d0132b2 commit 1ac2fc8

26 files changed

Lines changed: 4369 additions & 38 deletions

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **Scoped staging workspaces**`cas.workspaces.open()` now provides a
13+
renewable, RootSet-backed lifetime for intermediate asset, page, and bundle
14+
writes. Staging returns only after its typed handle is reachable from the
15+
exact workspace generation; checkpoint compaction, witness-checked
16+
destination-first cache or publication promotion, idempotent checked
17+
release, and serialized mutations preserve explicit ownership under
18+
concurrency. Expiry is
19+
observable posture rather than automatic revocation. Namespace-bounded
20+
inspection and checked sweep expose root count, age, expiry, logical content
21+
bytes, unique direct-root object bytes, invalid state, conflicts, and
22+
truncation. Repository doctor includes the same workspace inventory without
23+
claiming deduplicated or packed physical-byte attribution. Workspace
24+
witnesses reuse the existing `root-set` kind, and workspace-detail truncation
25+
uses a dedicated code without widening closed public discriminant unions.
26+
1027
## [6.3.0] — 2026-07-17
1128

1229
### Added

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ The README is the front door. Detailed mechanics live in the guide set:
102102
| CDC internals, Merkle manifests, KDF policy, and tuning | [Advanced Guide](./ADVANCED_GUIDE.md) |
103103
| Ports, adapters, and collaborator boundaries | [Architecture](./ARCHITECTURE.md) |
104104
| Assets, pages, bundles, retention, and publication | [Application storage](./docs/API.md#application-storage) |
105+
| Temporary retention during multi-step composition | [Scoped staging workspaces](./docs/API.md#scoped-staging-workspaces) |
105106
| GC retention for caches and derived state | [Root Sets](./docs/API.md#root-sets) |
106107
| Managed TTL and capacity caches | [Cache Sets](./docs/API.md#cache-sets) |
107108
| Scoped protection while consuming a cache hit | [Cache acquisitions](./docs/API.md#acquire-and-release) |
@@ -139,13 +140,17 @@ Core capabilities:
139140
`publications` compose streaming CAS writes, bounded structured
140141
materializations, targeted member reads, reachability roots,
141142
compare-and-swap refs, and immutable lifecycle evidence.
143+
- **Scoped staging workspaces**: `workspaces.open()` mirrors application writes
144+
behind one renewable temporary RootSet, returns only after each handle is
145+
anchored, promotes destination-first, and exposes bounded age, expiry,
146+
logical-content, and direct-root diagnostics with opaque cleanup pagination.
142147
- **Envelope recipients**: multi-recipient key wrapping and recipient rotation
143148
avoid re-encrypting data blobs.
144149
- **Operational diagnostics**: `cas.diagnostics.doctor()` streams repository
145150
object/ref evidence, classifies anchored, orphaned, and volatile objects,
146-
and summarizes cache acquisitions, CacheSet, RootSet, ExpiringSet, and Vault
147-
usage without mutating Git. The `git-cas doctor` CLI continues to validate
148-
vault health.
151+
and summarizes workspaces, cache acquisitions, CacheSet, RootSet,
152+
ExpiringSet, and Vault usage without mutating Git. The `git-cas doctor` CLI
153+
continues to validate vault health.
149154

150155
## Safety Snapshot
151156

docs/API.md

Lines changed: 163 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,18 @@ should treat restored data, chunker output, codec output, and keys as
1616

1717
1. [ContentAddressableStore](#contentaddressablestore)
1818
2. [Application Storage](#application-storage)
19-
3. [Root Sets](#root-sets)
20-
4. [Cache Sets](#cache-sets)
21-
5. [Expiring Sets](#expiring-sets)
22-
6. [Repository Diagnostics](#repository-diagnostics)
23-
7. [Vault](#vault)
24-
8. [CasService](#casservice)
25-
9. [Events](#events)
26-
10. [Value Objects](#value-objects)
27-
11. [Ports](#ports)
28-
12. [Codecs](#codecs)
29-
13. [Error Codes](#error-codes)
19+
3. [Scoped Staging Workspaces](#scoped-staging-workspaces)
20+
4. [Root Sets](#root-sets)
21+
5. [Cache Sets](#cache-sets)
22+
6. [Expiring Sets](#expiring-sets)
23+
7. [Repository Diagnostics](#repository-diagnostics)
24+
8. [Vault](#vault)
25+
9. [CasService](#casservice)
26+
10. [Events](#events)
27+
11. [Value Objects](#value-objects)
28+
12. [Ports](#ports)
29+
13. [Codecs](#codecs)
30+
14. [Error Codes](#error-codes)
3031

3132
## ContentAddressableStore
3233

@@ -1239,6 +1240,137 @@ generation later.
12391240
`toJSON()` serializes `handle` to its canonical token and copies the root
12401241
evidence fields.
12411242

1243+
## Scoped Staging Workspaces
1244+
1245+
Scoped staging workspaces retain intermediate application handles while a
1246+
caller builds a larger asset or bundle. They are the high-level temporary
1247+
reachability API. Use them instead of managing Git refs, raw CAS objects, or a
1248+
general CacheSet as a build scratchpad.
1249+
1250+
```javascript
1251+
const workspace = await cas.workspaces.open({
1252+
namespace: 'git-warp/materializations',
1253+
ttlMs: 2 * 60 * 60 * 1000,
1254+
});
1255+
1256+
try {
1257+
const first = await workspace.pages.put({ source: firstShard });
1258+
const second = await workspace.pages.put({ source: secondShard });
1259+
const bundle = await workspace.bundles.putOrdered({
1260+
members: [
1261+
['shards/first.cbor', first.handle],
1262+
['shards/second.cbor', second.handle],
1263+
],
1264+
});
1265+
1266+
await workspace.checkpoint({ handles: [bundle.handle] });
1267+
return await workspace.promoteToCache({
1268+
cache,
1269+
key: materializationKey,
1270+
handle: bundle.handle,
1271+
options: { retention: 'evictable' },
1272+
});
1273+
} finally {
1274+
await workspace.release();
1275+
}
1276+
```
1277+
1278+
`cas.workspaces.open({ namespace, ttlMs })` creates an in-memory workspace. It
1279+
does not create a Git ref until the first successful stage or checkpoint. The
1280+
default TTL is two hours; the maximum is seven days. `ttlMs` must be a positive
1281+
safe integer.
1282+
1283+
The workspace mirrors only application-storage writes:
1284+
1285+
```javascript
1286+
await workspace.assets.put(options);
1287+
await workspace.assets.adopt(options);
1288+
await workspace.pages.put(options);
1289+
await workspace.bundles.put(options);
1290+
await workspace.bundles.putOrdered(options);
1291+
```
1292+
1293+
Each method returns only after a direct workspace generation reaches the
1294+
returned typed handle. The result is otherwise the ordinary staged result plus
1295+
a workspace `RetentionWitness`. Calls on one workspace serialize their ref
1296+
mutations so concurrent staging cannot silently lose an accumulated root.
1297+
1298+
This guarantee starts when the method returns. Like all Git object composition,
1299+
the object-write-to-ref-update interval still relies on Git's ordinary
1300+
unreachable-object grace period. Running immediate-expiry prune concurrently
1301+
inside that interval is unsupported.
1302+
1303+
### Checkpoint, renew, promote, and release
1304+
1305+
`checkpoint({ handles })` replaces the active roots with the unique supplied
1306+
handles. Use it after an aggregate bundle transitively reaches its components.
1307+
An empty checkpoint removes active targets and installs a descriptor-only lease
1308+
generation, leaving the workspace available for later staging.
1309+
1310+
`renew()` preserves the current target set and advances its lease descriptor.
1311+
Successful staging, checkpoint, and promotion preparation also renew the
1312+
workspace.
1313+
1314+
`promoteToCache({ cache, key, handle, options })` and
1315+
`promoteToPublication({ handle, commit, ref })` establish destination
1316+
retention before releasing the workspace. The promoted handle must belong to
1317+
the active generation. The destination must return an anchored witness for the
1318+
exact handle, ref, and generation. Rejection, missing evidence, or destination
1319+
failure leaves the workspace intact and reports
1320+
`WORKSPACE_PROMOTION_NOT_RETAINED` where applicable. If destination retention
1321+
succeeds but workspace cleanup conflicts, the operation fails with
1322+
`WORKSPACE_PROMOTION_CLEANUP_PENDING`; its metadata distinguishes retained
1323+
destination state from pending temporary cleanup.
1324+
1325+
`release()` is idempotent for one workspace object and deletes only its exact
1326+
observed direct-ref generation. A conflicting generation or symbolic ref fails
1327+
closed. Calling any staging, checkpoint, renewal, or promotion method after a
1328+
successful release fails with `WORKSPACE_RELEASED`.
1329+
1330+
### Inspect and sweep
1331+
1332+
Workspace expiry is operational posture, not automatic revocation. An expired
1333+
workspace remains Git-reachable until an explicit checked sweep removes it.
1334+
1335+
```javascript
1336+
const inspection = await cas.workspaces.inspect({
1337+
namespace: 'git-warp/materializations',
1338+
limit: 100,
1339+
});
1340+
1341+
let cursor = null;
1342+
do {
1343+
const cleanup = await cas.workspaces.sweep({
1344+
namespace: 'git-warp/materializations',
1345+
limit: 100,
1346+
cursor,
1347+
});
1348+
cursor = cleanup.nextCursor;
1349+
} while (cursor !== null);
1350+
```
1351+
1352+
Inspection is namespace-scoped and bounded. Each record reports identity,
1353+
generation, direct-root count, creation time, age, expiry, posture, and two
1354+
different byte measures:
1355+
1356+
- `logicalBytes` sums the validated semantic content size reported by each
1357+
retained typed handle. Overlapping handles can describe overlapping content.
1358+
- `rootObjectBytes` sums each unique direct Git root object's loose logical
1359+
size. It excludes transitive support, deduplication attribution, pack
1360+
compression, and filesystem overhead.
1361+
1362+
Inspection validates the canonical target name, typed handle, OID, Git object
1363+
type, and complete application handle graph. Invalid persisted state produces
1364+
`posture: 'invalid'` with structured issue evidence instead of a guessed byte
1365+
count.
1366+
1367+
`sweep()` deletes only records observed as expired, direct, and still at the
1368+
same generation. Inspection and sweep return an opaque `nextCursor` whenever
1369+
`truncated` is true; pass that cursor to the next call so active or invalid
1370+
records cannot starve later expired workspaces. The sweep result reports
1371+
inspected, changed, conflicted, missing, and truncated counts. Sweep never
1372+
removes active or invalid records.
1373+
12421374
## Root Sets
12431375

12441376
Root sets retain a mutable current set of Git blobs or trees. They are intended
@@ -1786,14 +1918,15 @@ const report = await cas.diagnostics.doctor({
17861918
console.log(report.repository.objects);
17871919
console.log(report.usage.acquisitions);
17881920
console.log(report.usage.caches);
1921+
console.log(report.usage.workspaces);
17891922
console.log(report.limitations);
17901923
```
17911924

17921925
Pass either `gracePeriodMs` or an exact canonical `expiresBefore` UTC timestamp,
17931926
not both. The default grace period is 14 days. `maxCollectionsPerKind` bounds
1794-
detailed cache-acquisition, CacheSet, RootSet, and ExpiringSet rows from 1
1795-
through 1000; its default is 100. Every managed collection or acquisition is
1796-
still inspected sequentially and included in `totals`. Coverage reports
1927+
detailed cache-acquisition, CacheSet, RootSet, ExpiringSet, and workspace rows
1928+
from 1 through 1000; its default is 100. Every managed collection or
1929+
acquisition is still inspected sequentially and included in `totals`. Coverage reports
17971930
`observed`, `inspected`, `detailed`, and `complete`, so detail truncation is
17981931
visible instead of silently dropping managed refs or undercounting repository
17991932
usage.
@@ -1831,9 +1964,12 @@ retention ref remains healthy, `ageMs` is `null`, and the entry reports
18311964
`maxAgeMs` is `null` when any active acquisition age is incomparable. Cache
18321965
summaries report entry count, deterministic logical bytes, age,
18331966
expiry, capacity policy, and pinned/evictable counts. RootSet policy counts and
1834-
Vault entry counts are reported independently from reachability. A privacy-mode
1835-
vault remains healthy but reports `entryCount: null` because repository doctor
1836-
does not request or retain vault key material.
1967+
Vault entry counts are reported independently from reachability. Workspace
1968+
summaries report active and expired counts, validated logical bytes, and unique
1969+
direct-root object bytes; detailed rows carry creation age, exact expiry, and
1970+
invalid-state evidence. Neither workspace byte field is physical residency. A
1971+
privacy-mode vault remains healthy but reports `entryCount: null` because
1972+
repository doctor does not request or retain vault key material.
18371973

18381974
Git's `for-each-ref` does not enumerate dangling symbolic refs. Repository
18391975
doctor therefore reports symbolic acquisition refs that Git returns, but does
@@ -3195,6 +3331,16 @@ new CasError({ message, code, meta, documentationUrl });
31953331
| `ROOT_SET_METADATA_INVALID` | `.rootset.json` is malformed, non-canonical, or belongs to another ref | `read()`, `list()`, `doctor()` |
31963332
| `ROOT_SET_TREE_INVALID` | Metadata and the Git tree's actual reachability edges disagree | `read()`, `list()`, `doctor()` |
31973333
| `ROOT_SET_REF_UPDATE_FAILED` | Root-set ref update failed for a non-conflict reason | Root-set mutations and repair |
3334+
| `WORKSPACE_REF_INVALID` | Workspace ref namespace, identity, epoch, or canonical encoding is invalid | Workspace creation, inspection, and cleanup |
3335+
| `WORKSPACE_DESCRIPTOR_INVALID` | Workspace lease descriptor is malformed, non-canonical, inconsistent, or outside bounds | Workspace renewal, inspection, and cleanup |
3336+
| `WORKSPACE_STATE_INVALID` | Retained target names, handles, OIDs, types, counts, or byte evidence disagree | Workspace staging, checkpoint, inspection, and doctor |
3337+
| `WORKSPACE_TTL_INVALID` | Workspace TTL or computed expiry is outside supported bounds | `workspaces.open()`, staging, checkpoint, and renewal |
3338+
| `WORKSPACE_CONFLICT` | Workspace generation changed during a checked operation | Workspace inspection and sweep |
3339+
| `WORKSPACE_RELEASED` | A mutating operation targeted an already released workspace | Workspace staging, checkpoint, renewal, and promotion |
3340+
| `WORKSPACE_RETENTION_FAILED` | A low-level stage succeeded but its handle could not be proven in a workspace generation; metadata carries the staged receipt | Workspace asset, page, and bundle staging |
3341+
| `WORKSPACE_HANDLE_NOT_RETAINED` | Promotion requested a handle absent from the active workspace generation | `promoteToCache()`, `promoteToPublication()` |
3342+
| `WORKSPACE_PROMOTION_NOT_RETAINED` | Destination did not return exact anchored retention evidence, so the workspace remains active | `promoteToCache()`, `promoteToPublication()` |
3343+
| `WORKSPACE_PROMOTION_CLEANUP_PENDING` | Destination retention succeeded but checked workspace release failed | `promoteToCache()`, `promoteToPublication()` |
31983344
| `REPOSITORY_INSPECTION_INVALID` | Repository doctor options, Git output, dependencies, or safe-integer totals are invalid | `cas.diagnostics.doctor()`, repository inspection adapter |
31993345
| `VAULT_ENTRY_NOT_FOUND` | Slug does not exist in vault | `removeFromVault()`, `resolveVaultEntry()` |
32003346
| `VAULT_ENTRY_EXISTS` | Slug already exists (use `force` to overwrite) | `addToVault()` |

0 commit comments

Comments
 (0)