Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .agents/pm/features/pm-vcs-change-bundles.toon

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions .agents/pm/history/pm-vcs-change-bundles.jsonl

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ jobs:
- name: Full Bun release gate
run: |
bun run release:check
bun test --timeout 120000 ./test/tracker-lenses.test.ts
bun test --timeout 120000 ./test/tracker-lenses.test.ts ./test/change-bundles.test.ts

# The gate prints percentages but not which branch was missed, and a
# coverage result that reproduces only on a runner is otherwise undebuggable
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ pm vcs init

---

## Capability-limited change bundles

Export selected native histories with an Ed25519-signed manifest of permitted
paths/branches, included objects and excluded objects. The receiver supplies its
own scope, trusted keys and exact branch leases. Cross-scope references refuse
before object import; signatures bind every manifest field. Unknown omissions
prevent a completeness claim. Native identities and complete selected ancestry
are preserved; mixed-scope commits refuse rather than widening authorization.

```sh
pm vcs bundle export selected.bundle --input request.json --key-file ../signing.pem --json
pm vcs bundle inspect selected.bundle --authorization authority.json
pm vcs bundle import selected.bundle --authorization authority.json --expected expected.json --json
```

The typed SDK exposes `exportChangeBundle`, `inspectChangeBundle` and
`importChangeBundle`. Both TOON and JSON reports contain compact counts and a
manifest digest. See the [request, receiver-policy and SDK example](docs/change-bundles.md)
for exact schemas, replay limits, omission semantics and refusal remedies.

## Why write another version control system

Git is superb at what it was built for. It was built for source files, and its merge is a
Expand Down Expand Up @@ -276,7 +296,7 @@ are recorded as evidence on the item. See [the full bisect contract](docs/bisect

## Without git

Native commits, refs, history, merges, bundles, transports, signing, annotation,
Native commits, refs, history, merges, capability-limited change bundles, transports, signing, annotation,
bisect, semantic validation checkpoints and tracker change lenses work in a plain-directory PM project without a git executable. Bisect
predicates must supply their own dependencies. The optional `vcs git` interoperability
commands need a git repository and refuse with an actionable diagnostic otherwise.
Expand Down
61 changes: 61 additions & 0 deletions change-bundle-commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/** Thin native bundle commands; the pm host owns registration and compact TOON/JSON rendering. */
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import type { CommandHandlerContext, ExtensionApi } from "@unbrained/pm-cli/sdk/authoring";
import { ChangeBundleRefusal, exportChangeBundle, importChangeBundle, inspectChangeBundle, parseChangeBundleAuthorization, parseChangeBundleScope } from "./engine/change-bundles.ts";
import { readSigningKey } from "./engine/signatures.ts";
import { openRepository, optionalString, requiredArgument } from "./vcs-commands.ts";

/** Require an explicit file option rather than guessing an authority or a signing key. */
function fileOption(context: CommandHandlerContext, name: string): string {
const value = optionalString(context.options, name);
if (value === undefined) throw new ChangeBundleRefusal("invalid_change_bundle", "Pass the documented input, authorization, expected and key-file options required by this command.");
return resolve(value);
}
/** Bound untrusted input before parsing and sanitize filesystem and JSON diagnostics. */
function bytes(path: string): Buffer {
try {
const value = readFileSync(path);
if (value.length > 33554432) throw new Error("Oversized input.");
return value;
} catch { throw new ChangeBundleRefusal("invalid_change_bundle", "Supply a readable input file of at most 32 MiB."); }
}
/** JSON input carries explicit scopes and receiver policy; never resolve policy from the incoming bundle. */
function input(context: CommandHandlerContext, name: string): Record<string, unknown> {
try {
const value: unknown = JSON.parse(bytes(fileOption(context, name)).toString("utf8"));
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Object required.");
return value as Record<string, unknown>;
} catch { throw new ChangeBundleRefusal("invalid_change_bundle", "Pass a readable JSON object using the documented request or receiver-policy schema."); }
}
/** Register signed export, read-only inspect and explicit-CAS import without invoking Git. */
export function registerChangeBundleCommands(api: ExtensionApi): void {
const file = [{ name: "file", required: true, description: "Capability-limited change bundle file" }];
const authorization = { long: "--authorization", value_name: "file", value_type: "string" as const, description: "Independent receiver scope and native trusted-key policy JSON" };
api.registerCommand({ name: "vcs bundle export", description: "Export explicitly authorized native tips with a signed scope and omission manifest", arguments: file,
flags: [{ long: "--input", value_name: "file", value_type: "string", description: "JSON with scope, tips and signer" },
{ long: "--key-file", value_name: "file", value_type: "string", description: "External Ed25519 private key" }],
run(context: CommandHandlerContext) {
const repository = openRepository(context); const request = input(context, "input");
if (Object.keys(request).sort().join(",") !== "scope,signer,tips") throw new ChangeBundleRefusal("invalid_change_bundle", "Supply only scope, tips and signer in the export request; field projection and omission extensions are unsupported.");
const scope = parseChangeBundleScope(request.scope);
const selected = request.tips as Record<string, string>;
const key = readSigningKey(fileOption(context, "keyFile"), repository.root);
const archive = exportChangeBundle(repository.objects, selected, scope, request.signer as string, key);
const output = resolve(requiredArgument(context, 0, "file", "Pass a new output file for the signed change bundle."));
try { writeFileSync(output, archive, { flag: "wx", mode: 0o600 }); }
catch { throw new ChangeBundleRefusal("invalid_change_bundle", "Choose a new writable output file; existing files are never overwritten."); }
return { ok: true, bytes: archive.length };
} });
for (const action of ["inspect", "import"] as const) api.registerCommand({ name: `vcs bundle ${action}`,
description: action === "inspect" ? "Verify bundle scope, trust, closure and omissions without importing" : "Import a signed native bundle within independent authority and exact destination leases",
arguments: file, flags: [authorization, ...(action === "import" ? [{ long: "--expected", value_name: "file", value_type: "string" as const, description: "Exact branch-to-current-id JSON; null requires a new branch" }] : [])],
run(context: CommandHandlerContext) {
const repository = openRepository(context);
const archive = bytes(resolve(requiredArgument(context, 0, "file", "Pass the signed change bundle file.")));
const policy = parseChangeBundleAuthorization(input(context, "authorization"));
const report = action === "inspect" ? inspectChangeBundle(repository.objects, archive, policy)
: importChangeBundle(repository, archive, policy, input(context, "expected") as Record<string, string | null>);
return { ok: true, ...report };
} });
}
171 changes: 171 additions & 0 deletions docs/change-bundles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Capability-limited change bundles

Use `vcs bundle` to exchange selected **native histories** between ordinary
directories. Git is unnecessary. The sender explicitly selects immutable tips,
destination branch names and exact authorized paths. All selected ancestors and
their complete trees must fit that scope. This preserves commit and stable change
identities. Selecting a path from a mixed-scope commit refuses; create a separate
native history with that authorized content before exporting.

The receiver supplies its own scope, trusted keys and expected destination values.
The sender's signed scope limits that authority further. Neither embedded keys nor
sender-selected branch names grant receiver permission. Import admits objects and
publishes only the named branches; it does not check out files, execute commands,
replay tracker mutations, change HEAD, adopt clone identity, or import erasure
authority. Local signature and semantic checkpoint publication policies still
apply. Import validates all destination names before admitting objects, including
parent/child collisions within the bundle, existing local refs and empty ref
directories. Such a refusal publishes no refs and stores no objects. Immediately
before ref publication, under the shared store lease and all destination ref locks,
import rechecks the receiver grant and current revocation times, and reloads any
local trusted-key revocations for the manifest signer. Admitted objects may remain
when a publication-time signer revocation or local checkpoint rule refuses
the ref update, just as with ordinary native transport. A capability limits this
operation; it cannot restrict subsequent actions by the receiving repository owner.

## Example

First create a native commit containing only authorized files, for example the
tracked `review.toon` file. Obtain its exact commit id from `pm vcs log --json`.
Create an Ed25519 private key outside the working directory. Use the SPKI DER public
key encoded as canonical base64 for receiver trust, as with native change signing.
Keep private keys and local authorization files out of version control.

Sender `request.json` (replace the example digest with the actual native tip):

```json
{
"scope": { "paths": ["review.toon"], "refs": ["refs/heads/review"] },
"tips": { "refs/heads/review": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
"signer": "reviewer@example.invalid"
}
```

```sh
pm vcs bundle export selected.bundle --input request.json --key-file ../signing.pem --json
```

Independently prepare receiver `authority.json`, replacing `PUBLIC_KEY_BASE64` with
the sender's verified public key. Existing native trusted-key policies use this
same `trust` shape. The receiver may authorize a broader set of paths and branches;
the signed manifest still limits the imported set.

```json
{
"scope": { "paths": ["review.toon"], "refs": ["refs/heads/review"] },
"trust": {
"version": 1,
"keys": [{ "identity": "reviewer@example.invalid", "publicKey": "PUBLIC_KEY_BASE64" }],
"revocations": []
}
}
```

Receiver `expected.json` requires a new branch:

```json
{ "refs/heads/review": null }
```

```sh
pm vcs bundle inspect selected.bundle --authorization authority.json
pm vcs bundle import selected.bundle --authorization authority.json --expected expected.json --json
```

For an existing branch, replace `null` with its independently read current native
commit id. Tip and expected-value maps must match the declared scope ref by ref;
commas are literal characters in a ref name, never branch-set separators.
Every imported branch needs exactly one expected value. A mismatch
refuses before importing objects. Import deliberately leaves the native index and
working files alone; inspect or check out the admitted history separately.

The default TOON and `--json` receipts contain only a manifest digest and counts:
`objects`, `excluded`, `changes`, `refs`, `unknownOmissions`, `scopeComplete` and
`complete`. No object contents, inventory arrays, credentials or tracker bodies
are expanded into agent output. Export reports the written byte count. The full
inventory remains in the signed file. Output files are created exclusively with
private permissions and an existing output file is never overwritten.

## SDK

The package entry point exports `exportChangeBundle`, `inspectChangeBundle`,
`importChangeBundle`, both policy parsers and their typed contracts:

```ts
const bytes = exportChangeBundle(sender.objects, tips, scope, signer, privateKey);
const report = inspectChangeBundle(receiver.objects, bytes, authorization);
const imported = importChangeBundle(receiver, bytes, authorization, expected);
```

Scopes contain sorted, unique exact relative paths (1..1000) and full branch names
(1..100). Tags, globs, traversal, implicit dependency expansion and unsupported
fields refuse. Arrays use native UTF-8 byte ordering. Export is bounded to 32 MiB;
inventory arrays are bounded to 100,000 ids each. Large streaming transfers remain
the separate Phase 6 workstream.

## Manifest and refusals

The version 1 manifest binds scope, exact branch tips, included and excluded object
ids, unknown omissions, archive SHA-256, signer and public key. Ed25519 signs
`pm-vcs/change-bundle\0v1\0` followed by canonical native-record JSON bytes. The
canonical envelope embeds that manifest, signature and native archive. Its distinct
format marker prevents accidental import through ordinary `vcs import`. The inner
archive carries no prerequisites, partial-history promises, clone identity or
erasure metadata. Native hashing and role-aware closure validation remain the
authority for objects, trees, conflicts and fragment manifests.

Both export and import validate every selected historical tree path. Native
checkpoint scope/dependency paths and `copiedFrom` file identities must also stay
within selected history. External repository links and non-history roots such as
series/tombstones refuse. Historical tracker attribution and ordinary record
field values are data, not grants to fetch or mutate their referenced resources.
No source tip, parent, payload, conflict side or fragment may be silently supplied
from the receiver's existing store. Duplicate, unrelated and missing objects
refuse before storage, even when signed by a trusted sender.

Excluded ids enumerate verified physical objects held by the source but outside
the selected closure at export time; their payloads are not included. This is a
signed source-inventory statement, not proof of all objects that ever existed or
independent proof of the sender's honesty. Partial-history or erasure state sets
`unknownOmissions: true`. `scopeComplete: true` certifies the validated selected
native closure only. `complete: true` additionally requires no excluded objects
and no unknown omissions. A selective archive therefore never claims to reproduce
the complete source project.

`ChangeBundleRefusal` exposes `code` and `remedy` for malformed contracts,
cross-scope references, untrusted/revoked/tampered signatures, incomplete closure,
stale leases and destination ref collisions (`bundle_ref_conflict`). Fix the named
input, narrow the selected history, independently authorize all referenced scope,
restore missing objects, or review fresh destination
values as directed. Core native integrity, erasure, signature and checkpoint
refusals retain their existing typed codes. Ordinary forge/tenant authorization
and durable upload receipt expiry are separate contracts.

## Public tracker validation

Measured against the committed public pm-vcs tracker at
`641d2d66d31308f0171df2c666e18dc1d27aee03`, using an archive copy in a plain
directory. The SDK read the selected real items; native commits first captured
the five authorized task files and then the remaining public item inventory.
Only the first native change was authorized for export. Both export and receiver
inspection/import ran with an empty executable search path and a Git discovery
ceiling around the scratch projects.

| Measurement | Result |
| --- | ---: |
| Source tracker items | 158 |
| Selected / excluded item files | 5 / 153 |
| Source native objects | 173 |
| Carried / excluded objects | 10 / 163 |
| Selected native changes / destination branches | 1 / 1 |
| Signed bundle bytes | 31,013 |
| Export + inspect + import elapsed time | 187 ms |
| Source item files verified unchanged | 158 |

The receipt reported `scopeComplete: true`, `complete: false` and
`unknownOmissions: false`. A subsequent cross-scope replay refused and the
receiver inventory stayed exact. All copied item bytes were compared after the
run; their combined path-and-content SHA-256 was
`1d3eb73203b89eaf0156c0c15478b063d96c494bbbef558be0f9e760a9b656b2`.
This bounded measurement establishes selected-history behavior, not large-file
streaming throughput or hosted-backend acceptance.
5 changes: 3 additions & 2 deletions engine/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,16 @@ export interface ObjectImportReport {
*
* @param store - Object store holding every object in the header's object list.
* @param header - The validated bundle header.
* @param metadata - Carry repository identity and erasure metadata; false for independently authorized closed archives.
* @returns The bundle bytes.
*/
export function serializeBundle(store: ObjectStore, header: BundleContents): Buffer {
export function serializeBundle(store: ObjectStore, header: BundleContents, metadata = true): Buffer {
const objects = header.objects.filter(/** Typed absence is transferred as audit metadata, never payload. */ (id) => store.denial(id) === undefined);
const denials = store.denials();
if (denials.some(/** Incomplete physical deletion cannot be exported as a successful terminal state. */ (denial) => denial.pending)) throw new ObjectStoreError("erasure_incomplete", "Finish erasure cleanup before exporting history.");
const identity = store.recordedIdentity();
const enriched: BundleContents = { ...header, objects,
...(identity === undefined ? {} : { identity }), ...(denials.length === 0 ? {} : { denials }) };
...(!metadata || identity === undefined ? {} : { identity }), ...(!metadata || denials.length === 0 ? {} : { denials }) };
const lines = [BUNDLE_FORMAT, JSON.stringify(enriched)];
for (const id of objects) {
const object = store.read(id);
Expand Down
Loading
Loading