@lapxo/bound 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,17 +2,31 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync, lstatSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join, sep } from 'node:path';
4
4
  import { canonical } from '@lapxo/topos/wire';
5
- import { fieldOf, isWire } from "../fold/claims.js";
5
+ import { fieldOf, isWire, isConfig } from "../fold/claims.js";
6
6
  import { archiveCoordinate, archiveDestination, members } from "./archive.js";
7
7
  import { blobAt, holdStore, landBlob, replaceWhole, storeAt } from "../land/ledger.js";
8
8
  import { coordinatesUnder, observeFile, observeText } from "../observe/files.js";
9
9
  export const laidAt = (store, digest) => storeAt(store, 'cas', 'laid', digest.replace(/^[^:]*:/, ''));
10
- export const releasesOf = (lock) => lock.filter((line) => fieldOf(line, 'scope').startsWith('dep/') && fieldOf(line, 'role') === 'reads' && fieldOf(line, 'value') !== 'withdraw');
10
+ /** A host artifact binding, not a measured ceiling or an object act. */
11
+ export const isReleaseBinding = (line) => isConfig(line)
12
+ && fieldOf(line, 'scope').startsWith('dep/') && fieldOf(line, 'role') === 'reads'
13
+ && fieldOf(line, 'value') !== 'withdraw'
14
+ && (fieldOf(line, 'measure') === 'digest' || /^[a-zA-Z][a-zA-Z0-9-]*:[a-f0-9]+$/.test(fieldOf(line, 'value')));
15
+ export const releasesOf = (lock) => lock.filter(isReleaseBinding);
11
16
  const laidMark = '.laid';
12
17
  const digestOf = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
13
18
  const hashed = (digest, bytes) => createHash(digest.includes(':') ? digest.slice(0, digest.indexOf(':')) : 'sha256').update(bytes).digest('hex') === digest.replace(/^[^:]*:/, '');
14
19
  const snapshotOf = (text) => text.split('\n').filter(isWire).filter((line) => fieldOf(line, 'measure') === 'digest' && fieldOf(line, 'scope').startsWith('file/'))
15
20
  .map((line) => [fieldOf(line, 'scope').slice('file/'.length), fieldOf(line, 'value')]);
21
+ /** Mount an explicitly declared archive coordinate; never infer a package root. */
22
+ function mountedFiles(files, root) {
23
+ if (!root)
24
+ return files;
25
+ const prefix = archiveCoordinate(root) + '/';
26
+ if (!files.length || files.some(([name]) => !name.startsWith(prefix)))
27
+ throw new Error(`REFUSE·release archive root ${root} does not contain every member`);
28
+ return files.map(([name, digest]) => [archiveCoordinate(name.slice(prefix.length)), digest]);
29
+ }
16
30
  function snapshotLines(files) {
17
31
  return files.map(([coord, digest]) => canonical({
18
32
  scope: `file/${coord}`, role: 'writes', form: 'alphabet', measure: 'digest', value: digest, at: `place:${digest}`, by: 'target',
@@ -110,6 +124,11 @@ function keepSnapshot(store, digest, bytes) {
110
124
  export function layReleases(store, own, lock, contentStore = own) {
111
125
  let laid = 0;
112
126
  for (const line of releasesOf(lock)) {
127
+ // A release catalog entry without a mount is materialized only when requested.
128
+ // Its absence must not block an unrelated place's fold.
129
+ const needs = fieldOf(line, 'needs');
130
+ if (!needs)
131
+ continue;
113
132
  const digest = fieldOf(line, 'value');
114
133
  const at = laidAt(store, digest);
115
134
  const dir = lstatSync(at, { throwIfNoEntry: false })?.isDirectory() ?? false;
@@ -125,20 +144,18 @@ export function layReleases(store, own, lock, contentStore = own) {
125
144
  throw new Error(`REFUSE·release snapshot for ${digest} omits or changes archive members; explicit rebuild required`);
126
145
  if (bytes === undefined && !dir && current === undefined)
127
146
  throw new Error(`REFUSE·release ${fieldOf(line, 'scope')} has no available archive or snapshot`);
128
- const needs = fieldOf(line, 'needs');
129
- if (!needs)
130
- continue;
147
+ const root = fieldOf(line, 'shape');
131
148
  const place = join(own, archiveCoordinate(needs));
132
149
  const link = lstatSync(place, { throwIfNoEntry: false });
133
150
  if (link?.isSymbolicLink() && !realpathSync(place).includes(`${sep}cas${sep}laid${sep}`))
134
151
  continue;
135
152
  if (dir) {
136
- const files = bytes === undefined ? undefined : members(bytes).map((one) => [one.name, digestOf(one.bytes)]);
153
+ const files = bytes === undefined ? undefined : mountedFiles(members(bytes).map((one) => [one.name, digestOf(one.bytes)]), root);
137
154
  if (!lstatSync(place, { throwIfNoEntry: false })?.isSymbolicLink() && observeText(join(place, laidMark)) === digest && files !== undefined && layoutMatches(place, files))
138
155
  continue;
139
156
  const next = nextPlace(place);
140
157
  try {
141
- layDir(at, next);
158
+ layDir(root ? join(at, archiveCoordinate(root)) : at, next);
142
159
  swapPlace(place, next, digest);
143
160
  }
144
161
  catch (error) {
@@ -151,11 +168,12 @@ export function layReleases(store, own, lock, contentStore = own) {
151
168
  const snap = observeText(at);
152
169
  if (snap === undefined)
153
170
  throw new Error(`REFUSE·release ${fieldOf(line, 'scope')} has no available snapshot`);
154
- if (!lstatSync(place, { throwIfNoEntry: false })?.isSymbolicLink() && observeText(join(place, laidMark)) === digest && layoutMatches(place, snapshotOf(snap)))
171
+ const mounted = mountedFiles(snapshotOf(snap), root);
172
+ if (!lstatSync(place, { throwIfNoEntry: false })?.isSymbolicLink() && observeText(join(place, laidMark)) === digest && layoutMatches(place, mounted))
155
173
  continue;
156
174
  const next = nextPlace(place);
157
175
  try {
158
- layFiles(store, contentStore, snapshotOf(snap), next);
176
+ layFiles(store, contentStore, mounted, next);
159
177
  swapPlace(place, next, digest);
160
178
  }
161
179
  catch (error) {
@@ -180,12 +198,12 @@ export const resolvedOf = (store, own, lock, contentStore = own) => releasesOf(l
180
198
  const historicalDirectory = lstatSync(at, { throwIfNoEntry: false })?.isDirectory() === true;
181
199
  const snapshot = observeText(at);
182
200
  const complete = expected !== undefined && (needs
183
- ? layoutMatches(place, expected)
201
+ ? layoutMatches(place, mountedFiles(expected, fieldOf(line, 'shape')))
184
202
  : historicalDirectory ? layoutMatches(at, expected) : snapshot !== undefined && sameSnapshot(snapshotOf(snapshot), expected));
185
203
  return ok && laid && complete ? [canonical({ scope: `resolved/${fieldOf(line, 'scope').slice('dep/'.length)}`, role: 'writes', form: 'alphabet', measure: 'digest', value: digest, at: `place:${digest}`, by: 'target' })] : [];
186
204
  });
187
205
  /** A host loads verified release bytes from a runtime tree, never interprets a snapshot file as a directory. */
188
- export function runtimeTreeAt(store, own, digest, contentStore = own) {
206
+ export function runtimeTreeAt(store, own, digest, contentStore = own, root = '') {
189
207
  const named = /^([a-zA-Z0-9-]+):([a-fA-F0-9]+)$/.exec(digest);
190
208
  if (named === null)
191
209
  throw new Error(`REFUSE·release invalid digest ${digest}`);
@@ -195,9 +213,11 @@ export function runtimeTreeAt(store, own, digest, contentStore = own) {
195
213
  if (!hashed(digest, bytes))
196
214
  throw new Error(`REFUSE·release ${digest} does not name its available bytes`);
197
215
  const files = members(bytes).map((one) => [one.name, digestOf(one.bytes)]);
216
+ mountedFiles(files, root); // Validate the same declared projection used for installation.
198
217
  const place = storeAt(store, 'cas', 'runtime', named[1], named[2]);
218
+ const selected = root ? join(place, archiveCoordinate(root)) : place;
199
219
  if (observeText(join(place, laidMark)) === digest && layoutMatches(place, files))
200
- return place;
220
+ return selected;
201
221
  for (const member of members(bytes))
202
222
  landBlob(store, digestOf(member.bytes), member.bytes);
203
223
  const next = nextPlace(place);
@@ -209,5 +229,5 @@ export function runtimeTreeAt(store, own, digest, contentStore = own) {
209
229
  rmSync(next, { recursive: true, force: true });
210
230
  throw error;
211
231
  }
212
- return place;
232
+ return selected;
213
233
  }
@@ -2,7 +2,7 @@ import { capsuleAt } from './capsule.ts';
2
2
  import type { Capsule } from './capsule.ts';
3
3
  import type { ToposResolver } from './selected-topos.ts';
4
4
  /** Only explicit capsule offers request execution. Forms/classes/views do not need an executable alias. */
5
- export declare function selectedCapsules(pins: readonly string[], resolve?: typeof capsuleAt, select?: ToposResolver): readonly {
5
+ export declare function selectedCapsules(pins: readonly string[], resolve?: typeof capsuleAt, select?: ToposResolver, host?: readonly string[]): readonly {
6
6
  readonly capsule: Capsule;
7
7
  readonly offer: string;
8
8
  }[];
@@ -4,13 +4,17 @@ import { selectedTopoi, toposAt } from "./selected-topos.js";
4
4
  import { canonical } from '@lapxo/topos/wire';
5
5
  import { fieldOf } from "../fold/claims.js";
6
6
  import { ownLock } from "../observe/runner.js";
7
+ import { archiveCoordinate } from "./archive.js";
7
8
  /** Only explicit capsule offers request execution. Forms/classes/views do not need an executable alias. */
8
- export function selectedCapsules(pins, resolve = capsuleAt, select = toposAt) {
9
+ export function selectedCapsules(pins, resolve = capsuleAt, select = toposAt, host = ownLock()) {
9
10
  return selectedTopoi(pins, select).flatMap(({ digest, topos }) => capsuleOffers(topos).map(offer => {
10
- const projections = ownLock().filter(line => fieldOf(line, 'scope').startsWith('dep/') && fieldOf(line, 'role') !== 'reads' && fieldOf(line, 'value') === offer.value);
11
- if (projections.length !== 1 || !fieldOf(projections[0], 'shape'))
11
+ const projections = host.filter(line => fieldOf(line, 'scope').startsWith('dep/') && fieldOf(line, 'role') === 'writes' && fieldOf(line, 'value') === offer.value);
12
+ if (!projections.length || projections.some(line => !fieldOf(line, 'shape')))
12
13
  throw Error(`REFUSE·pin ${digest} capsule ${offer.value} needs one host projection`);
13
- const projected = { ...offer, shape: fieldOf(projections[0], 'shape') };
14
+ const entries = [...new Set(projections.map(line => archiveCoordinate(fieldOf(line, 'shape'))))];
15
+ if (entries.length !== 1)
16
+ throw Error(`REFUSE·pin ${digest} capsule ${offer.value} has conflicting host projections`);
17
+ const projected = { ...offer, shape: entries[0] };
14
18
  const capsule = resolve(offer.value, projected.shape);
15
19
  if (capsule === undefined)
16
20
  throw Error(`REFUSE·pin ${digest} · ${offer.scope} · offered capsule ${offer.value} unavailable`);
@@ -1,4 +1,5 @@
1
1
  import type { StandingTopos } from '@lapxo/topos/standing';
2
+ import { wireAt } from '@lapxo/topos/wire';
2
3
  export interface SelectedTopos {
3
4
  readonly digest: string;
4
5
  readonly topos: StandingTopos;
@@ -15,3 +16,6 @@ export declare function selectedTopoi(pins: readonly string[], resolve?: ToposRe
15
16
  * Independent declarations must agree; selection order cannot choose a contract.
16
17
  */
17
18
  export declare function selectedWireList(local: readonly string[], epoch: number, pins: readonly string[], list: string, resolve?: ToposResolver): readonly string[];
19
+ /** Complete an admission snapshot from the selected standing. Local declarations
20
+ * govern their own lists; independent selected contracts cannot choose by order. */
21
+ export declare function selectedWireAt(local: readonly Readonly<Record<string, string>>[], epoch: number, pins: readonly string[], resolve?: ToposResolver): ReturnType<typeof wireAt>;
@@ -59,3 +59,26 @@ export function selectedWireList(local, epoch, pins, list, resolve = toposAt) {
59
59
  throw Error(`REFUSE·wire wire/${list} has conflicting contracts · ${contracts.map(one => one.name).join(' · ')}`);
60
60
  return first.words;
61
61
  }
62
+ /** Complete an admission snapshot from the selected standing. Local declarations
63
+ * govern their own lists; independent selected contracts cannot choose by order. */
64
+ export function selectedWireAt(local, epoch, pins, resolve = toposAt) {
65
+ const own = wireAt(local, epoch);
66
+ const worlds = selectedTopoi(pins, resolve).map(({ digest, topos }) => ({ digest, parts: topos.parts, wire: wireAt(topos.parts, Number.MAX_SAFE_INTEGER) }));
67
+ const key = (scope) => scope.startsWith('wire/') ? scope.slice(5) : scope.startsWith('audit/wire/') ? scope.slice(11) : undefined;
68
+ const declared = new Set(local.filter(f => Number(f.epoch ?? 0) <= epoch).map(f => key(f.scope ?? '')).filter((k) => k !== undefined));
69
+ const selected = new Map();
70
+ for (const world of worlds)
71
+ for (const f of world.parts) {
72
+ const name = key(f.scope ?? '');
73
+ if (name === undefined || declared.has(name))
74
+ continue;
75
+ const list = world.wire?.lists.get(name);
76
+ const value = list ? [...list].sort().join('|') : f.value ?? '';
77
+ const previous = selected.get(name);
78
+ if (previous && previous.value !== value)
79
+ throw Error(`REFUSE·wire wire/${name} has conflicting selected contracts · ${previous.digest} · ${world.digest}`);
80
+ if (!previous)
81
+ selected.set(name, { value, fields: f, digest: world.digest });
82
+ }
83
+ return selected.size ? wireAt([...selected.values()].map(one => one.fields).concat(local), epoch) : own;
84
+ }
@@ -0,0 +1,3 @@
1
+ import type { SigningLot } from './ports/signing.ts';
2
+ /** Resolve data before acquisition; all mechanisms use one wire protocol and verification path. */
3
+ export declare function signerFor(lines: readonly string[], keyId: string, selected?: string, keyFile?: string): (lot: SigningLot) => Promise<readonly import("./ports/signing.ts").SignatureRecord[]>;
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parse, signerSelection, signerRequestBytes, signerResponseBytes, signerResponseOf } from '@lapxo/topos/wire';
3
+ import { invokeSigner, SigningFailure } from "./ports/signing.js";
4
+ /** An explicitly supplied host registry; a repository cannot supply executable bindings. */
5
+ function binding(name, keyFile) {
6
+ if (name === 'file') {
7
+ if (!keyFile)
8
+ throw new SigningFailure('REFUSE·signer file needs --key-file');
9
+ return { kind: 'file', path: keyFile };
10
+ }
11
+ if (keyFile)
12
+ throw new SigningFailure('REFUSE·signer command cannot use --key-file');
13
+ const path = process.env['BOUND_SIGNERS'];
14
+ if (!path)
15
+ throw new SigningFailure('REFUSE·signer no host binding registry');
16
+ try {
17
+ const registry = JSON.parse(readFileSync(path, 'utf8'));
18
+ if (!registry || typeof registry !== 'object' || !Object.prototype.hasOwnProperty.call(registry, name))
19
+ throw Error();
20
+ const record = registry[name];
21
+ if (!record || typeof record !== 'object')
22
+ throw Error();
23
+ const f = record;
24
+ if (f['kind'] !== 'command' || typeof f['executable'] !== 'string' || !f['executable']
25
+ || !Array.isArray(f['args']) || !f['args'].every(a => typeof a === 'string'))
26
+ throw Error();
27
+ return { kind: 'command', executable: f['executable'], args: f['args'] };
28
+ }
29
+ catch {
30
+ throw new SigningFailure('REFUSE·signer host binding unavailable or invalid');
31
+ }
32
+ }
33
+ /** Resolve data before acquisition; all mechanisms use one wire protocol and verification path. */
34
+ export function signerFor(lines, keyId, selected, keyFile) {
35
+ const fields = lines.flatMap(line => { const got = parse(line); return got.kind === 'fact' ? [got.value.fields] : []; });
36
+ const declaration = signerSelection(fields, keyId);
37
+ if (selected !== undefined && selected !== declaration.name)
38
+ throw new SigningFailure(`REFUSE·signer ${keyId} CLI selection conflicts with declaration`);
39
+ const mechanism = binding(declaration.name, keyFile);
40
+ const protocol = { request: signerRequestBytes, response: signerResponseBytes, reply: signerResponseOf };
41
+ return (lot) => invokeSigner(lot, mechanism, protocol, declaration);
42
+ }
@@ -7,4 +7,4 @@ export interface ContentPort {
7
7
  /** Transport uses declared locations; only the requested digest admits bytes into the cache. */
8
8
  export declare function resolveSelectedContent(lines: readonly string[], port: ContentPort): Promise<readonly string[]>;
9
9
  /** Exact resource URLs; no repository checkout or inferred release. */
10
- export declare function resolveSources(standing: readonly string[]): Promise<void>;
10
+ export declare function resolveSources(standing: readonly string[], materializeInstrument?: boolean): Promise<void>;
@@ -8,7 +8,10 @@ import { ownLock, ownStore } from "../observe/runner.js";
8
8
  export async function resolveSelectedContent(lines, port) {
9
9
  const fields = lines.flatMap(line => { const p = parse(line); return p.kind === 'fact' && p.value.fields.value !== 'withdraw' ? [p.value.fields] : []; });
10
10
  const selectionIndex = (scope) => { const match = /(^|\/)uses\//.exec(scope); return match === null ? -1 : match.index + match[1].length; };
11
- const bindings = fields.filter(f => selectionIndex(f.scope ?? '') >= 0 || f.scope?.startsWith('dep/'));
11
+ // A dependency constraint is not a content binding. Historical artifact records
12
+ // use measure=id; explicit digest records still validate even when malformed.
13
+ const bindings = fields.filter(f => selectionIndex(f.scope ?? '') >= 0 ||
14
+ (f.scope?.startsWith('dep/') && (f.measure === 'digest' || /^[a-zA-Z][a-zA-Z0-9-]*:[a-f0-9]+$/.test(f.value ?? ''))));
12
15
  const locations = new Map();
13
16
  for (const binding of bindings) {
14
17
  const scope = binding.scope ?? '';
@@ -73,12 +76,19 @@ export async function resolveSelectedContent(lines, port) {
73
76
  return received;
74
77
  }
75
78
  /** Exact resource URLs; no repository checkout or inferred release. */
76
- export async function resolveSources(standing) {
79
+ export async function resolveSources(standing, materializeInstrument = false) {
77
80
  const instrument = ownLock().flatMap(line => { const p = parse(line); return p.kind === 'fact' ? [p.value.fields] : []; });
78
81
  const wire = wireAt(instrument, Number.MAX_SAFE_INTEGER);
79
82
  if (wire === null)
80
83
  throw Error('REFUSE·pin instrument has no admitted wire');
81
- const received = await resolveSelectedContent([...standing, ...ownLock().filter(line => { const p = parse(line); return p.kind === 'fact' && (p.value.fields.scope?.startsWith('dep/') || p.value.fields.scope?.startsWith('sources/')); })], {
84
+ const bindings = ownLock().filter(line => {
85
+ const p = parse(line);
86
+ if (p.kind !== 'fact')
87
+ return false;
88
+ const fields = p.value.fields;
89
+ return fields.scope?.startsWith('sources/') || (fields.scope?.startsWith('dep/') && (fields.role !== 'reads' || (materializeInstrument && !!fields.needs)));
90
+ });
91
+ const received = await resolveSelectedContent([...standing, ...bindings], {
82
92
  algorithms: wire.digests,
83
93
  read: digest => observeFile(blobAt(ownStore(), digest)),
84
94
  write: (digest, bytes) => { landBlob(ownStore(), digest, bytes); },
@@ -1,16 +1,15 @@
1
- type Signed = {
1
+ import type { SigningLot, SignatureRecord } from '../host/ports/signing.ts';
2
+ /** Prepare and verify a whole lot. No key material and no output/admission side effects. */
3
+ export declare function signConsentLot(lines: readonly string[], stamp: {
4
+ readonly epoch?: number;
5
+ readonly by: string;
6
+ readonly algorithm: string;
7
+ }, publicKey: string, admitted: readonly string[], invoke: (lot: SigningLot) => Promise<readonly SignatureRecord[]>): Promise<{
2
8
  readonly kind: 'fact';
3
- readonly line: string;
9
+ readonly lines: readonly string[];
4
10
  } | {
5
11
  readonly kind: 'refuse';
6
12
  readonly why: string;
7
- };
8
- /** A consent line (by=target, no sig) becomes the owner's claim: stamped with its epoch, then signed over the canonical bytes. */
9
- export declare function signConsentLine(line: string, privateKeyPem: string, stamp: {
10
- readonly epoch?: number;
11
- readonly by: string;
12
- readonly algorithm: string;
13
- }): Signed;
13
+ }>;
14
14
  export declare function verifiesOwnerLine(line: string, publicKeyPem: string, admitted: readonly string[]): boolean;
15
15
  export declare function verifiesFields(fields: Readonly<Record<string, string>>, publicKey: string, admitted: readonly string[]): boolean;
16
- export {};
package/dist/land/sign.js CHANGED
@@ -1,21 +1,44 @@
1
1
  import { canonical, parse, parseForSigning, signedBytes, signedFieldsOf } from '@lapxo/topos/wire';
2
- import { signBytes, verifyBytes } from "../host/adapters/keys/ed25519.js";
3
- /** A consent line (by=target, no sig) becomes the owner's claim: stamped with its epoch, then signed over the canonical bytes. */
4
- export function signConsentLine(line, privateKeyPem, stamp) {
2
+ import { verifyBytes } from "../host/adapters/keys/ed25519.js";
3
+ import { SigningFailure } from "../host/ports/signing.js";
4
+ function prepareConsentLine(line, stamp) {
5
5
  const got = parseForSigning(line);
6
6
  if (got.kind !== 'fact')
7
- return { kind: 'refuse', why: `REFUSE·accept not a claim line: ${got.why}` };
7
+ return { kind: 'refuse', why: 'REFUSE·accept not a claim line' };
8
8
  const fields = got.value.fields;
9
9
  const scope = fields['scope'] ?? '?';
10
10
  if (fields['sig'] !== undefined)
11
11
  return { kind: 'refuse', why: `REFUSE·accept ${scope} is already signed` };
12
- if (fields['by'] !== 'target') {
13
- return { kind: 'refuse', why: `REFUSE·accept ${scope} is by=${fields['by'] ?? '∅'}; only by=target lines are signed` };
14
- }
12
+ if (fields['by'] !== 'target')
13
+ return { kind: 'refuse', why: `REFUSE·accept ${scope} must be drafted by=target` };
15
14
  const covered = signedFieldsOf(fields, stamp.by);
16
15
  if (stamp.epoch !== undefined)
17
16
  covered['epoch'] = String(stamp.epoch);
18
- return { kind: 'fact', line: canonical({ ...covered, sig: signBytes(signedBytes(covered), privateKeyPem, stamp.algorithm) }) };
17
+ return { kind: 'fact', fields: covered };
18
+ }
19
+ /** Prepare and verify a whole lot. No key material and no output/admission side effects. */
20
+ export async function signConsentLot(lines, stamp, publicKey, admitted, invoke) {
21
+ const fields = [];
22
+ for (const line of lines) {
23
+ const prepared = prepareConsentLine(line, stamp);
24
+ if (prepared.kind === 'refuse')
25
+ return prepared;
26
+ fields.push(prepared.fields);
27
+ }
28
+ if (!fields.length)
29
+ return { kind: 'refuse', why: `REFUSE·signer ${stamp.by} empty lot` };
30
+ let records;
31
+ try {
32
+ records = await invoke({ bytes: fields.map(f => signedBytes(f)), keyId: stamp.by, algorithm: stamp.algorithm });
33
+ }
34
+ catch (error) {
35
+ return { kind: 'refuse', why: error instanceof SigningFailure ? error.message : `REFUSE·signer ${stamp.by} acquisition failed` };
36
+ }
37
+ if (records.length !== fields.length || records.some((record, i) => record.keyId !== stamp.by
38
+ || !verifyBytes(signedBytes(fields[i]), publicKey, record.signature, admitted))) {
39
+ return { kind: 'refuse', why: `REFUSE·signer ${stamp.by} signature does not verify` };
40
+ }
41
+ return { kind: 'fact', lines: fields.map((f, i) => canonical({ ...f, sig: records[i].signature })) };
19
42
  }
20
43
  export function verifiesOwnerLine(line, publicKeyPem, admitted) {
21
44
  const got = parse(line);
@@ -1,5 +1,5 @@
1
1
  import { selectedCapsules } from "../host/selected-capsules.js";
2
- import { selectionLines } from "../fold/reach.js";
2
+ import { selectionLines, projectionLines } from "../fold/reach.js";
3
3
  export { selectionLines } from "../fold/reach.js";
4
4
  import { createHash } from 'node:crypto';
5
5
  import { basename, dirname, join, relative } from 'node:path';
@@ -125,7 +125,7 @@ export function capsulesOf(fold) {
125
125
  const cached = heldCapsules.get(fold);
126
126
  if (cached !== undefined)
127
127
  return cached;
128
- const got = selectedCapsules(selectionLines(fold)).map(one => one.capsule);
128
+ const got = selectedCapsules(selectionLines(fold), undefined, undefined, projectionLines(fold)).map(one => one.capsule);
129
129
  heldCapsules.set(fold, got);
130
130
  return got;
131
131
  }
@@ -21,7 +21,8 @@ const moduleAt = fromBlob() ? undefined : fileURLToPath(import.meta.url);
21
21
  const runtimeAt = moduleAt === undefined ? '' : resolve(dirname(moduleAt), '..');
22
22
  const runtimeExtension = moduleAt === undefined ? '' : extname(moduleAt);
23
23
  export const ownRoot = () => (fromBlob() ? join(resolve(dirname(self()), '..', '..', '..'), 'bound') : resolve(runtimeAt, '..'));
24
- export const ownStore = () => join(ownRoot(), '..', EXTENSION);
24
+ export const ownStore = () => existsSync(join(ownRoot(), EXTENSION, 'ledger'))
25
+ ? join(ownRoot(), EXTENSION) : join(ownRoot(), '..', EXTENSION);
25
26
  /** The colocated public artifact is read without an unrelated parent's authority store. */
26
27
  // An act is judged by the instrument it started with, even when that act changes its colocated lock.
27
28
  let instrumentLock;
@@ -30,7 +31,9 @@ export const ownLock = () => {
30
31
  return instrumentLock;
31
32
  const lines = (observeText(join(ownRoot(), LOCK)) ?? '').split('\n').filter(isWire);
32
33
  const published = publicLock(lines);
33
- instrumentLock = published === undefined ? ownLockOf(join(ownRoot(), '..'), basename(ownRoot()), true) : foldClaims(published).standing;
34
+ instrumentLock = published === undefined
35
+ ? dirname(ownStore()) === ownRoot() ? ownLockOf(ownRoot(), '', true) : ownLockOf(join(ownRoot(), '..'), basename(ownRoot()), true)
36
+ : foldClaims(published).standing;
34
37
  return instrumentLock;
35
38
  };
36
39
  export const sourceOf = () => (fromBlob() ? join(ownRoot(), 'src', 'cli', 'verb.ts') : self());
@@ -166,7 +166,8 @@ export function prerender(fold, views) {
166
166
  capsuleViews(fold, due.map((view) => ({ asking: view.regions.filter((asked) => REGIONS[asked.name] === undefined), shape: view.shape })));
167
167
  }
168
168
  export function viewOf(fold, view) {
169
- const live = !view.shape;
169
+ // Native receipts project current file bytes, not only the cached fold's observations.
170
+ const live = !view.shape || view.regions.some(asked => asked.name === 'receipts');
170
171
  const kept = !live && fold.key ? keptRender(fold.store, fold.key, view.name) : undefined;
171
172
  if (kept !== undefined)
172
173
  return kept;
package/docs/usage.md CHANGED
@@ -12,11 +12,11 @@ Start by asking for help and folding the project. If a selected view is unavaila
12
12
 
13
13
  ## Keys and coverage
14
14
 
15
- Use a named key already admitted by the project. The authority contract declares its public key, class and scope coverage. The private key is a matching PKCS8 PEM file outside the repository. Point `--key-file` at that file and `--key` at the admitted identifier.
15
+ Use a named key already admitted by the project. The authority contract declares its public key, class and scope coverage. Signing uses a declared port. The lock names `keys/<who> measure=signer value=<name>`; `signer/timeout` and `signer/response-bytes` bound each invocation. The built-in `file` port reads a matching PKCS8 PEM outside the repository through `--key-file`. For a command port, the host supplies a `BOUND_SIGNERS` registry mapping that admitted name to `{ "kind": "command", "executable": "...", "args": [] }`. A repository declaration cannot authorize a program. `--signer <name>` must match the admitted selection.
16
16
 
17
17
  A new device must have its public key and coverage admitted by an authority that already covers the intended scope. The CLI does not currently certify silent key creation or tap-to-sign. This guide does not substitute a self-authorized fixture key for the team's authority.
18
18
 
19
- For automation, supply the private key through the runner's secret storage to a temporary external file with restricted permissions. Do not print, commit or upload it. A source-control token and a Bound authority key have different purposes. Key rotation and revocation are changes to the admitted authority contract, not edits that erase prior signatures.
19
+ For automation through the file port, supply the private key through the runner's secret storage to a temporary external file with restricted permissions. A command signer receives the lot once as canonical signed bytes, one line per record in order. It returns one canonical wire response per record, containing `by` and `sig`, in that same order. Bound verifies the entire reply against the admitted public key before any admission. Timeout, nonzero exit, missing or invalid replies refuse the whole lot; raw signer output is not logged. Hosts can keep private keys outside Bound through this port. Key generation, storage and rotation belong to the host. Do not print, commit or upload it. A source-control token and a Bound authority key have different purposes. Key rotation and revocation are changes to the admitted authority contract, not edits that erase prior signatures.
20
20
 
21
21
  ## Proposal → signature → admission → reading
22
22
 
@@ -42,7 +42,7 @@ receipts.bound is native fold output. Do not write it by hand, use JSON from a t
42
42
 
43
43
  REFUSE·wire means the current grammar does not admit the record. REFUSE·pin/release means named authentic content or its closure is unavailable or does not match. REFUSE·view means the requested view cannot be rendered under its contract. Authority refusal means the signer is not covered. Keep the line and the failed exit status; follow an explicit fix without changing the named digest or dropping a conflicting statement.
44
44
 
45
- release/blocks is policy read by the fold. The existing policy is incomplete because it has no admitted category links. An incomplete policy is not READY and is not permission to fabricate selectors.
45
+ release/blocks is policy read by the fold. Its admitted category links select existing obligations. The fold prints each missing reading or crossed ceiling as BLOCK; a blocked or incomplete policy is not READY. Read the captured verdict for the exact candidate instead of inferring acceptance from a successful render.
46
46
 
47
47
  ## Repository and PR review
48
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lapxo/bound",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Read the bounds you already wrote, fold them, and name the contradictions.",
6
6
  "main": "dist/cli/verb.js",
@@ -32,7 +32,7 @@
32
32
  "license": "MIT",
33
33
  "dependencies": {
34
34
  "@lapxo/obligations": "0.3.5",
35
- "@lapxo/topos": "0.1.7"
35
+ "@lapxo/topos": "0.1.8"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "^20.0.0",
@@ -44,5 +44,21 @@
44
44
  },
45
45
  "bin": {
46
46
  "bound": "./dist/cli/verb.js"
47
- }
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/Lapxo/bound.git"
51
+ },
52
+ "homepage": "https://github.com/Lapxo/bound#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/Lapxo/bound/issues"
55
+ },
56
+ "keywords": [
57
+ "cli",
58
+ "content-addressed",
59
+ "provenance",
60
+ "receipts",
61
+ "constraints",
62
+ "reproducible"
63
+ ]
48
64
  }