@nanobpm/urban 0.68.0 → 0.69.0
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.
- package/dist/context/binding/index.d.ts +1 -1
- package/dist/context/binding/index.js +1 -1
- package/dist/context/git/guard-registry.d.ts +26 -0
- package/dist/context/git/guard-registry.js +52 -0
- package/dist/context/git/index.d.ts +4 -1
- package/dist/context/git/index.js +21 -1
- package/dist/context/git/layout.d.ts +46 -0
- package/dist/context/git/layout.js +96 -0
- package/dist/context/git/substrate.d.ts +95 -0
- package/dist/context/git/substrate.js +190 -0
- package/dist/context/git/writer.d.ts +154 -0
- package/dist/context/git/writer.js +395 -0
- package/package.json +2 -2
- package/src/context/binding/index.ts +1 -0
- package/src/context/git/NOTES.md +98 -0
- package/src/context/git/guard-registry.ts +59 -0
- package/src/context/git/index.ts +51 -2
- package/src/context/git/layout.test.ts +112 -0
- package/src/context/git/layout.ts +110 -0
- package/src/context/git/substrate.ts +287 -0
- package/src/context/git/writer.test.ts +898 -0
- package/src/context/git/writer.ts +543 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
// Slice S3 (git/governance) — git-as-system-of-record WRITE + governance.
|
|
2
|
+
//
|
|
3
|
+
// Builds the write and governance layer on top of a resolved substrate handle
|
|
4
|
+
// (from S1), the validated record schema (from S2), and the mandatory PII guard
|
|
5
|
+
// (from S6). It does NOT re-implement clone/pull (S1), the record schema (S2),
|
|
6
|
+
// or the PII classifier/guard (S6) — it composes them.
|
|
7
|
+
//
|
|
8
|
+
// Governance model (git-only MVP, no external services):
|
|
9
|
+
//
|
|
10
|
+
// - APPEND-VIA-COMMIT: a validated record is persisted as a commit on the base
|
|
11
|
+
// branch (append-only provenance). Used for human/measured/instance records —
|
|
12
|
+
// facts and human-authored priors that land directly.
|
|
13
|
+
// - PR-GOVERNANCE: an `agent-retro` prior is a PROPOSAL, written as a BOT branch
|
|
14
|
+
// (the git-only stand-in for a bot PR) that is NOT on the base branch until it
|
|
15
|
+
// is MERGED. A merge == ratification. Because the S2 schema forbids
|
|
16
|
+
// `agent-retro` from ever being `authoritative`, an unmerged (or even a
|
|
17
|
+
// merged) agent-retro record can never present as a measured/authoritative
|
|
18
|
+
// fact — governance decides only whether the *hypothesis* is accepted onto the
|
|
19
|
+
// authoritative line, never whether it becomes a fact.
|
|
20
|
+
// - CONCURRENCY: within a SINGLE ContextWriter instance, writes are serialised
|
|
21
|
+
// on the shared working tree (see #queue) and each still lands on its own
|
|
22
|
+
// uniquely-named branch, so interleaved calls never clobber each other and
|
|
23
|
+
// disjoint records merge cleanly. This guarantee is PER-INSTANCE: two
|
|
24
|
+
// ContextWriter instances (or separate processes) pointing at the SAME working
|
|
25
|
+
// copy are not coordinated and can interleave checkouts/commits and corrupt the
|
|
26
|
+
// tree — use a separate clone (or external locking) per concurrent writer.
|
|
27
|
+
// - PII BY CONSTRUCTION: the mandatory S6 guard is pre-registered in the guard
|
|
28
|
+
// registry and run before EVERY commit on the default code path. A caller that
|
|
29
|
+
// supplies no special wiring still gets it, so a PII-carrying write is rejected
|
|
30
|
+
// before it is committed.
|
|
31
|
+
|
|
32
|
+
import { randomUUID } from "node:crypto";
|
|
33
|
+
import type { PiiGuard } from "../pii/index.ts";
|
|
34
|
+
import {
|
|
35
|
+
assertMemoryRecord,
|
|
36
|
+
type MemoryProvenance,
|
|
37
|
+
type MemoryRecord,
|
|
38
|
+
} from "../schema/index.ts";
|
|
39
|
+
import { PreCommitGuardRegistry } from "./guard-registry.ts";
|
|
40
|
+
import { LAYOUT_ROOT, recordRelativePath } from "./layout.ts";
|
|
41
|
+
import {
|
|
42
|
+
type CommitAuthor,
|
|
43
|
+
GitWriteSubstrate,
|
|
44
|
+
type WriteSubstrate,
|
|
45
|
+
} from "./substrate.ts";
|
|
46
|
+
|
|
47
|
+
/** Thrown when a write violates the PR-governance rules of this layer. */
|
|
48
|
+
export class GovernanceError extends Error {
|
|
49
|
+
constructor(message: string) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "GovernanceError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The default author attributed to direct (human/measured/instance) appends. */
|
|
56
|
+
export const DEFAULT_WRITE_AUTHOR: CommitAuthor = {
|
|
57
|
+
name: "nano-context",
|
|
58
|
+
email: "context@nanobpm.local",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The bot author attributed to proposed priors and their ratifying merges. */
|
|
62
|
+
export const DEFAULT_BOT_AUTHOR: CommitAuthor = {
|
|
63
|
+
name: "nano-context-bot",
|
|
64
|
+
email: "context-bot@nanobpm.local",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The single source of truth for the proposal (bot "PR") branch namespace.
|
|
69
|
+
* `proposePrior` creates branches under it and `ratify` refuses to merge anything
|
|
70
|
+
* outside it, so an arbitrary caller-named branch can never be ratified.
|
|
71
|
+
*/
|
|
72
|
+
export const PROPOSAL_BRANCH_PREFIX = "context/proposal/" as const;
|
|
73
|
+
|
|
74
|
+
/** The minimal resolved-handle shape the writer consumes (from S1). */
|
|
75
|
+
export interface ResolvedSubstrateHandle {
|
|
76
|
+
/**
|
|
77
|
+
* Absolute path to the local substrate working copy. MUST be absolute —
|
|
78
|
+
* `GitWriteSubstrate` rejects a relative root. S1's `ResolvedContextHandle`
|
|
79
|
+
* guarantees this, but the requirement is not obvious from this type alone.
|
|
80
|
+
*/
|
|
81
|
+
readonly localPath: string;
|
|
82
|
+
readonly ref: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Options for {@link ContextWriter}. All optional; the defaults are safe. */
|
|
86
|
+
export interface ContextWriterOptions {
|
|
87
|
+
/** Override the write substrate (seam for a future mutable/PII backend). */
|
|
88
|
+
readonly substrate?: WriteSubstrate;
|
|
89
|
+
/**
|
|
90
|
+
* ADDITIONAL pre-commit guards. The mandatory S6 PII guard is always present
|
|
91
|
+
* (see {@link PreCommitGuardRegistry}); these can only make enforcement
|
|
92
|
+
* stricter, never bypass it.
|
|
93
|
+
*/
|
|
94
|
+
readonly guards?: readonly PiiGuard[];
|
|
95
|
+
/**
|
|
96
|
+
* The base branch records land on / are proposed against. Defaults to the
|
|
97
|
+
* resolved substrate handle's `ref` (the S1-resolved branch), falling back to
|
|
98
|
+
* the substrate's currently checked-out branch only when the handle carries no
|
|
99
|
+
* ref.
|
|
100
|
+
*/
|
|
101
|
+
readonly baseBranch?: string;
|
|
102
|
+
/** Author for direct appends. Defaults to {@link DEFAULT_WRITE_AUTHOR}. */
|
|
103
|
+
readonly author?: CommitAuthor;
|
|
104
|
+
/** Author for proposed priors + merges. Defaults to {@link DEFAULT_BOT_AUTHOR}. */
|
|
105
|
+
readonly botAuthor?: CommitAuthor;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The outcome of a direct append (ratified immediately on the base branch). */
|
|
109
|
+
export interface AppendResult {
|
|
110
|
+
/** Substrate-relative path the record was written to (see the layout helper). */
|
|
111
|
+
readonly path: string;
|
|
112
|
+
/** The commit that persisted the record on its write branch. */
|
|
113
|
+
readonly commit: string;
|
|
114
|
+
/** The merge commit that landed it on the base branch. */
|
|
115
|
+
readonly mergeCommit: string;
|
|
116
|
+
/** The base branch the record now lives on. */
|
|
117
|
+
readonly branch: string;
|
|
118
|
+
/** Always `true`: a direct append is ratified on landing. */
|
|
119
|
+
readonly ratified: true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The outcome of a proposed prior (a bot "PR"). The record lives on
|
|
124
|
+
* {@link branch} only — it is NOT on the base branch until {@link ContextWriter.ratify}
|
|
125
|
+
* merges it. Pass this handle back to `ratify` to accept the proposal.
|
|
126
|
+
*/
|
|
127
|
+
export interface ProposalResult {
|
|
128
|
+
/** Substrate-relative path the record was written to on the proposal branch. */
|
|
129
|
+
readonly path: string;
|
|
130
|
+
/** The commit on the proposal branch that carries the proposed record. */
|
|
131
|
+
readonly commit: string;
|
|
132
|
+
/** The proposal (bot "PR") branch. Not merged until ratified. */
|
|
133
|
+
readonly branch: string;
|
|
134
|
+
/** The base branch this proposal targets. */
|
|
135
|
+
readonly baseBranch: string;
|
|
136
|
+
/** A stable id for the proposal (useful for logging/correlation). */
|
|
137
|
+
readonly proposalId: string;
|
|
138
|
+
/** Always `false` on creation: a proposal is an unratified hypothesis. */
|
|
139
|
+
readonly ratified: false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The outcome of ratifying (merging) a proposed prior onto the base branch. */
|
|
143
|
+
export interface RatifyResult {
|
|
144
|
+
/** The merge commit that landed the proposal on the base branch. */
|
|
145
|
+
readonly mergeCommit: string;
|
|
146
|
+
/** The base branch the proposal was ratified onto. */
|
|
147
|
+
readonly baseBranch: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The git-as-system-of-record writer. One instance is bound to one resolved
|
|
152
|
+
* substrate handle. Operations are serialised within the instance (a single
|
|
153
|
+
* working copy has one working tree), so interleaved calls never race each
|
|
154
|
+
* other's checkout/commit; each still lands on its own branch, so nothing is
|
|
155
|
+
* clobbered.
|
|
156
|
+
*/
|
|
157
|
+
export class ContextWriter {
|
|
158
|
+
readonly #substrate: WriteSubstrate;
|
|
159
|
+
readonly #guards: PreCommitGuardRegistry;
|
|
160
|
+
readonly #baseBranchOverride?: string;
|
|
161
|
+
readonly #author: CommitAuthor;
|
|
162
|
+
readonly #botAuthor: CommitAuthor;
|
|
163
|
+
// A promise chain that serialises mutating operations on the shared working
|
|
164
|
+
// tree so two in-flight writes cannot interleave their checkout/commit steps.
|
|
165
|
+
#queue: Promise<unknown> = Promise.resolve();
|
|
166
|
+
|
|
167
|
+
constructor(handle: ResolvedSubstrateHandle, options: ContextWriterOptions = {}) {
|
|
168
|
+
this.#substrate = options.substrate ?? new GitWriteSubstrate(handle.localPath);
|
|
169
|
+
// The registry ALWAYS contains the mandatory PII guard; `guards` can only add.
|
|
170
|
+
this.#guards = new PreCommitGuardRegistry(options.guards ?? []);
|
|
171
|
+
this.#baseBranchOverride = options.baseBranch ?? (handle.ref || undefined);
|
|
172
|
+
this.#author = options.author ?? DEFAULT_WRITE_AUTHOR;
|
|
173
|
+
this.#botAuthor = options.botAuthor ?? DEFAULT_BOT_AUTHOR;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The pre-commit guards that run before every write (mandatory PII first). */
|
|
177
|
+
get guards(): readonly PiiGuard[] {
|
|
178
|
+
return this.#guards.guards;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* APPEND-VIA-COMMIT (direct, ratified-on-landing) write path for
|
|
183
|
+
* human/measured/instance records. Validates the record against the S2 schema
|
|
184
|
+
* (including the forgery-resistance invariants), runs the mandatory PII guard,
|
|
185
|
+
* then commits it on a fresh write branch and merges it onto the base branch.
|
|
186
|
+
*
|
|
187
|
+
* `agent-retro` records are REJECTED here by construction: an agent proposal is
|
|
188
|
+
* a hypothesis that must go through governance ({@link proposePrior} +
|
|
189
|
+
* {@link ratify}), never a direct append. This is what stops an unratified
|
|
190
|
+
* prior from landing on the authoritative line without a merge.
|
|
191
|
+
*/
|
|
192
|
+
appendRecord(input: unknown): Promise<AppendResult> {
|
|
193
|
+
return this.#serialise(async () => {
|
|
194
|
+
const record = assertMemoryRecord(input);
|
|
195
|
+
this.#assertDirectlyAppendable(record.provenance);
|
|
196
|
+
// MANDATORY PII enforcement BY CONSTRUCTION — before ANY commit.
|
|
197
|
+
this.#guards.assertAll(record);
|
|
198
|
+
|
|
199
|
+
const base = await this.#resolveBaseBranch();
|
|
200
|
+
const relPath = recordRelativePath(record);
|
|
201
|
+
const writeBranch = this.#branchName("append", record.id);
|
|
202
|
+
|
|
203
|
+
// A mid-operation failure (createBranch/write/commit/merge) must never leave
|
|
204
|
+
// the SHARED working tree parked on a transient write branch with a
|
|
205
|
+
// half-written record for the next serialised call to inherit; the `finally`
|
|
206
|
+
// best-effort restores the resolved base and discards any residue.
|
|
207
|
+
// Reduce the schema-validated (non-empty-string only) `id` to a single
|
|
208
|
+
// commit-message line before interpolation, so a newline in `id` can never
|
|
209
|
+
// inject extra commit-message lines or a forged trailer. Derived once and
|
|
210
|
+
// reused for both the append commit and its merge subject.
|
|
211
|
+
const idLine = commitLine(record.id);
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
await this.#substrate.createBranch(writeBranch, base);
|
|
215
|
+
await this.#substrate.writeRecordFile(relPath, serialiseRecord(record));
|
|
216
|
+
const commit = await this.#substrate.stageAndCommit(
|
|
217
|
+
relPath,
|
|
218
|
+
`context(append): ${record.provenance}/${record.scope} ${idLine}`,
|
|
219
|
+
this.#author,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
await this.#substrate.checkout(base);
|
|
223
|
+
const mergeCommit = await this.#substrate.mergeBranch(
|
|
224
|
+
writeBranch,
|
|
225
|
+
`context(append): land ${idLine} onto ${base}`,
|
|
226
|
+
this.#author,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
return { path: relPath, commit, mergeCommit, branch: base, ratified: true };
|
|
230
|
+
} finally {
|
|
231
|
+
await this.#restoreBase(base);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* PR-GOVERNANCE write path: write a record as a PROPOSED prior on a bot branch
|
|
238
|
+
* (the git-only stand-in for a bot PR). The record is committed on its own
|
|
239
|
+
* branch and left UNMERGED, so it is not on the base branch — an unratified
|
|
240
|
+
* hypothesis. Ratify it with {@link ratify}.
|
|
241
|
+
*
|
|
242
|
+
* This is the mandatory path for `agent-retro` records and is available for any
|
|
243
|
+
* record a caller wants to route through governance rather than land directly.
|
|
244
|
+
* The mandatory PII guard runs here too, before the commit.
|
|
245
|
+
*/
|
|
246
|
+
proposePrior(input: unknown): Promise<ProposalResult> {
|
|
247
|
+
return this.#serialise(async () => {
|
|
248
|
+
const record = assertMemoryRecord(input);
|
|
249
|
+
// MANDATORY PII enforcement BY CONSTRUCTION — before ANY commit.
|
|
250
|
+
this.#guards.assertAll(record);
|
|
251
|
+
|
|
252
|
+
const base = await this.#resolveBaseBranch();
|
|
253
|
+
const relPath = recordRelativePath(record);
|
|
254
|
+
const proposalId = `${sanitizeRef(record.id)}-${randomUUID().slice(0, 8)}`;
|
|
255
|
+
const proposalBranch = `${PROPOSAL_BRANCH_PREFIX}${proposalId}`;
|
|
256
|
+
|
|
257
|
+
// As with `appendRecord`, a mid-operation failure must not strand the shared
|
|
258
|
+
// working tree on the proposal branch; the `finally` restores the base. The
|
|
259
|
+
// proposal COMMIT is preserved on its own branch (a hypothesis until
|
|
260
|
+
// ratified) — restoring the base only moves the working tree off it, it does
|
|
261
|
+
// not discard the committed proposal.
|
|
262
|
+
try {
|
|
263
|
+
await this.#substrate.createBranch(proposalBranch, base);
|
|
264
|
+
await this.#substrate.writeRecordFile(relPath, serialiseRecord(record));
|
|
265
|
+
const commit = await this.#substrate.stageAndCommit(
|
|
266
|
+
relPath,
|
|
267
|
+
`context(propose): ${record.provenance}/${record.scope} ${commitLine(record.id)} [hypothesis]`,
|
|
268
|
+
this.#botAuthor,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
path: relPath,
|
|
273
|
+
commit,
|
|
274
|
+
branch: proposalBranch,
|
|
275
|
+
baseBranch: base,
|
|
276
|
+
proposalId,
|
|
277
|
+
ratified: false,
|
|
278
|
+
};
|
|
279
|
+
} finally {
|
|
280
|
+
// Leave the proposal branch UNMERGED — restoring the base only checks the
|
|
281
|
+
// working tree back onto it; the proposal stays an unratified hypothesis.
|
|
282
|
+
await this.#restoreBase(base);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Ratify a proposed prior by MERGING its bot branch onto the base branch. After
|
|
289
|
+
* this the proposal's record is on the authoritative line. Ratification records
|
|
290
|
+
* that the hypothesis was ACCEPTED; it does not (and cannot) upgrade an
|
|
291
|
+
* `agent-retro` record's authority to `authoritative` — the S2 schema forbids
|
|
292
|
+
* that, so the record remains an accepted hypothesis, never a forged fact.
|
|
293
|
+
*/
|
|
294
|
+
ratify(proposal: ProposalResult): Promise<RatifyResult> {
|
|
295
|
+
return this.#serialise(async () => {
|
|
296
|
+
if (!(await this.#substrate.branchExists(proposal.branch))) {
|
|
297
|
+
throw new GovernanceError(
|
|
298
|
+
`cannot ratify: proposal branch ${proposal.branch} does not exist.`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
// DEFENCE 1 — only a branch in the proposal namespace may be ratified, so a
|
|
302
|
+
// caller cannot merge an arbitrary local branch onto the authoritative line.
|
|
303
|
+
if (!proposal.branch.startsWith(PROPOSAL_BRANCH_PREFIX)) {
|
|
304
|
+
throw new GovernanceError(
|
|
305
|
+
`cannot ratify: ${proposal.branch} is not a proposal branch ` +
|
|
306
|
+
`(expected a ${PROPOSAL_BRANCH_PREFIX}* branch).`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
// DEFENCE 2 — the merge target is the writer's OWN resolved base branch, never
|
|
310
|
+
// a caller-supplied one. `ProposalResult` is a plain object, so a consumer can
|
|
311
|
+
// mutate `proposal.baseBranch` to steer the merge onto a different line than
|
|
312
|
+
// this writer is bound to (undermining the "one resolved handle" contract).
|
|
313
|
+
// Resolve the base ourselves and reject any proposal that targets a different
|
|
314
|
+
// one; every downstream step (diff, checkout, merge) uses `base`, not the
|
|
315
|
+
// untrusted `proposal.baseBranch`.
|
|
316
|
+
const base = await this.#resolveBaseBranch();
|
|
317
|
+
if (proposal.baseBranch !== base) {
|
|
318
|
+
throw new GovernanceError(
|
|
319
|
+
`cannot ratify: proposal targets base ${proposal.baseBranch}, but this ` +
|
|
320
|
+
`writer is bound to ${base}. A proposal may only be ratified onto the ` +
|
|
321
|
+
"base branch its writer resolved.",
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
// DEFENCE 3 — the merge must introduce EXACTLY the one proposed record file
|
|
325
|
+
// and nothing else. A proposal branch created/amended outside `proposePrior`
|
|
326
|
+
// could carry extra files (more records, or content outside the record
|
|
327
|
+
// layout) that a single-file re-read would never see; merging it would land
|
|
328
|
+
// that content UNGUARDED. Bounding the diff to `[proposal.path]` makes the
|
|
329
|
+
// file we re-guard below provably the ONLY candidate content being merged.
|
|
330
|
+
const changed = await this.#substrate.diffPaths(base, proposal.branch);
|
|
331
|
+
if (changed.length !== 1 || changed[0] !== proposal.path) {
|
|
332
|
+
throw new GovernanceError(
|
|
333
|
+
`cannot ratify: proposal branch ${proposal.branch} changes ` +
|
|
334
|
+
`${JSON.stringify(changed)}, expected exactly ["${proposal.path}"]. ` +
|
|
335
|
+
"A ratifying merge may introduce only its single proposed record file.",
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
// The pre-commit guard is a NON-OPTIONAL step on EVERY write path (S6), and
|
|
339
|
+
// a ratifying merge is a write onto the authoritative line. The proposal
|
|
340
|
+
// branch content is untrusted here: re-read (at the ref, without mutating the
|
|
341
|
+
// working tree), re-validate against the S2 schema, and re-run the mandatory
|
|
342
|
+
// PII guard BEFORE merging, so a PII-carrying (or otherwise invalid) record
|
|
343
|
+
// can never be ratified onto the base branch.
|
|
344
|
+
const proposed = await this.#substrate.readFileAtRef(proposal.branch, proposal.path);
|
|
345
|
+
const record = assertMemoryRecord(parseRecordJson(proposed, proposal.path));
|
|
346
|
+
this.#guards.assertAll(record);
|
|
347
|
+
// DEFENCE 4 — the on-disk path must be the record's canonical layout path, so
|
|
348
|
+
// a record can never be ratified under a path that mismatches its validated
|
|
349
|
+
// scope/scopeRef/id (which would make it invisible to layout-based retrieval).
|
|
350
|
+
const canonical = recordRelativePath(record);
|
|
351
|
+
if (proposal.path !== canonical) {
|
|
352
|
+
throw new GovernanceError(
|
|
353
|
+
`cannot ratify: proposal path ${proposal.path} does not match the ` +
|
|
354
|
+
`record's canonical layout ${canonical}.`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// A ratifying merge is a mutating op on the SHARED working tree, so — like
|
|
359
|
+
// `appendRecord` and `proposePrior` — its checkout+merge must run under a
|
|
360
|
+
// `finally` that restores the base. A `mergeBranch` failure (conflict, or a
|
|
361
|
+
// failed abort) would otherwise strand the tree on `base` mid-merge (or with
|
|
362
|
+
// conflict residue) for the next serialised op to inherit, breaking the
|
|
363
|
+
// class contract that every mutating op leaves the tree clean and on base.
|
|
364
|
+
try {
|
|
365
|
+
await this.#substrate.checkout(base);
|
|
366
|
+
// The merge commit message is derived from the GUARDED, schema-validated
|
|
367
|
+
// `record.id` (re-read and re-guarded above), never the caller-controlled
|
|
368
|
+
// `proposal.proposalId` on the plain-object handle. Interpolating the latter
|
|
369
|
+
// would let a consumer inject unguarded content (PII, newlines) into the
|
|
370
|
+
// commit message and bypass the mandatory PII guard, which only inspects
|
|
371
|
+
// record content. `commitLine` additionally collapses the id to a single
|
|
372
|
+
// line so even a guarded-but-newline-bearing id cannot inject a trailer.
|
|
373
|
+
const mergeCommit = await this.#substrate.mergeBranch(
|
|
374
|
+
proposal.branch,
|
|
375
|
+
`context(ratify): merge record ${commitLine(record.id)} onto ${base}`,
|
|
376
|
+
this.#botAuthor,
|
|
377
|
+
);
|
|
378
|
+
return { mergeCommit, baseBranch: base };
|
|
379
|
+
} finally {
|
|
380
|
+
await this.#restoreBase(base);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* `true` iff the proposal has been ratified (its proposal branch has been merged
|
|
387
|
+
* onto the base branch). A never-ratified proposal reads back as `false`, so an
|
|
388
|
+
* unmerged prior can never masquerade as a ratified one.
|
|
389
|
+
*
|
|
390
|
+
* The check requires the proposal branch to STILL EXIST under the proposal
|
|
391
|
+
* namespace (that existence is a deliberate part of the non-forgeability guard —
|
|
392
|
+
* see below). A proposal whose branch was merged and then DELETED therefore reads
|
|
393
|
+
* back `false`: this is a governance status query over live proposal branches, not
|
|
394
|
+
* a historical "was this commit ever merged" audit.
|
|
395
|
+
*/
|
|
396
|
+
isRatified(proposal: ProposalResult): Promise<boolean> {
|
|
397
|
+
return this.#serialise(async () => {
|
|
398
|
+
// Derive the ancestry check from TRUSTED data only — NEVER the caller-controlled
|
|
399
|
+
// `proposal.commit`. `ProposalResult` is a plain object, so a consumer can set
|
|
400
|
+
// `commit` to any commit already reachable from base (e.g. base's own HEAD) and
|
|
401
|
+
// read back a forged `true`, even though the proposal branch was never merged.
|
|
402
|
+
// First bound the branch to the proposal namespace and require it to exist, so a
|
|
403
|
+
// caller cannot point us at an arbitrary already-merged local branch (mirrors
|
|
404
|
+
// ratify's DEFENCE 1). A branch that fails either check is, by definition, not a
|
|
405
|
+
// ratified proposal.
|
|
406
|
+
if (
|
|
407
|
+
!proposal.branch.startsWith(PROPOSAL_BRANCH_PREFIX) ||
|
|
408
|
+
!(await this.#substrate.branchExists(proposal.branch))
|
|
409
|
+
) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
// Bind the ancestry check to the writer's OWN resolved base, never the
|
|
413
|
+
// caller-supplied `proposal.baseBranch`. That field is a plain-object handle
|
|
414
|
+
// a consumer can mutate to any ref where the proposal branch tip happens to be
|
|
415
|
+
// an ancestor, which would report an UNratified hypothesis as ratified. Reject a
|
|
416
|
+
// retargeted proposal outright (same trust boundary as ratify's DEFENCE 2).
|
|
417
|
+
const base = await this.#resolveBaseBranch();
|
|
418
|
+
if (proposal.baseBranch !== base) {
|
|
419
|
+
throw new GovernanceError(
|
|
420
|
+
`cannot check ratification: proposal targets base ${proposal.baseBranch}, ` +
|
|
421
|
+
`but this writer is bound to ${base}.`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
// Check ancestry against the proposal BRANCH TIP (re-resolved by git), never the
|
|
425
|
+
// untrusted `proposal.commit` hash: a proposal is ratified iff its branch has
|
|
426
|
+
// been merged onto the resolved base.
|
|
427
|
+
return this.#substrate.isMerged(proposal.branch, base);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
#assertDirectlyAppendable(provenance: MemoryProvenance): void {
|
|
432
|
+
if (provenance === "agent-retro") {
|
|
433
|
+
throw new GovernanceError(
|
|
434
|
+
"an 'agent-retro' prior is a hypothesis and must be routed through governance " +
|
|
435
|
+
"(proposePrior + ratify), not appended directly. Merging the proposal ratifies it.",
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async #resolveBaseBranch(): Promise<string> {
|
|
441
|
+
return this.#baseBranchOverride ?? (await this.#substrate.currentBranch());
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Best-effort return of the shared working tree to `base`, discarding any
|
|
445
|
+
// uncommitted/untracked residue under the record layout. Runs in the `finally`
|
|
446
|
+
// of each mutating op so a failure never strands the tree on a transient branch;
|
|
447
|
+
// swallows its own error so it can never mask the original operation's failure.
|
|
448
|
+
async #restoreBase(base: string): Promise<void> {
|
|
449
|
+
try {
|
|
450
|
+
await this.#substrate.restoreClean(base, LAYOUT_ROOT);
|
|
451
|
+
} catch {
|
|
452
|
+
// best-effort only — never mask the original operation's error.
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
#branchName(kind: string, id: string): string {
|
|
457
|
+
return `context/${kind}/${sanitizeRef(id)}-${randomUUID().slice(0, 8)}`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Serialise mutating operations: chain each onto the previous so the shared
|
|
461
|
+
// working tree is only ever touched by one operation at a time. Failures are
|
|
462
|
+
// isolated — a rejected operation does not poison the queue for the next call.
|
|
463
|
+
#serialise<T>(op: () => Promise<T>): Promise<T> {
|
|
464
|
+
const run = this.#queue.then(op, op);
|
|
465
|
+
this.#queue = run.then(
|
|
466
|
+
() => undefined,
|
|
467
|
+
() => undefined,
|
|
468
|
+
);
|
|
469
|
+
return run;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Serialise a record to deterministic, pretty-printed JSON with a trailing LF.
|
|
475
|
+
* Keys are emitted in a canonical (sorted) order so two semantically identical
|
|
476
|
+
* records serialise byte-for-byte identically regardless of the key INSERTION
|
|
477
|
+
* order they were constructed with — `JSON.stringify` otherwise preserves
|
|
478
|
+
* insertion order, which would let equal records produce differing output. The
|
|
479
|
+
* key set is DERIVED from the record itself (not a hand-maintained list), so a
|
|
480
|
+
* newly added field is never silently dropped from the serialised form.
|
|
481
|
+
*/
|
|
482
|
+
export function serialiseRecord(record: MemoryRecord): string {
|
|
483
|
+
return `${JSON.stringify(record, Object.keys(record).sort(), 2)}\n`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Parse proposal-branch JSON, surfacing malformed content as a GovernanceError. */
|
|
487
|
+
function parseRecordJson(content: string, path: string): unknown {
|
|
488
|
+
try {
|
|
489
|
+
return JSON.parse(content);
|
|
490
|
+
} catch (cause) {
|
|
491
|
+
throw new GovernanceError(
|
|
492
|
+
`cannot ratify: proposed record at ${path} is not valid JSON: ${
|
|
493
|
+
cause instanceof Error ? cause.message : String(cause)
|
|
494
|
+
}`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Reduce a value to a single commit-message line: collapse every whitespace run
|
|
501
|
+
* (spaces, tabs, and crucially CR/LF) to one space, trim, and cap the length. The
|
|
502
|
+
* record `id` is schema-validated only as a NON-EMPTY string, so without this a
|
|
503
|
+
* newline in `id` would split the commit subject and inject extra message lines or
|
|
504
|
+
* a forged trailer (e.g. a fake `Signed-off-by:`), and an unbounded-length `id`
|
|
505
|
+
* would produce a huge commit subject/merge message that degrades git and
|
|
506
|
+
* log/telemetry handling (the on-disk path is already length-bounded by
|
|
507
|
+
* `sanitizeSegment`). This is the categorical guard for both defect classes at
|
|
508
|
+
* every commit/merge interpolation site; distinct from `sanitizeRef`, which builds
|
|
509
|
+
* git-ref-safe branch segments.
|
|
510
|
+
*
|
|
511
|
+
* The schema validates `id` as a NON-EMPTY string (`length > 0`), which admits a
|
|
512
|
+
* whitespace-only id (e.g. `" "`). Collapsing then trimming that yields the empty
|
|
513
|
+
* string, which would leave a commit/merge subject with a MISSING id — degrading
|
|
514
|
+
* auditability and log correlation. Fall back to an explicit placeholder so every
|
|
515
|
+
* subject carries an unambiguous, non-empty token in that case.
|
|
516
|
+
*/
|
|
517
|
+
const MAX_COMMIT_LINE = 120;
|
|
518
|
+
const EMPTY_COMMIT_LINE = "<empty>";
|
|
519
|
+
function commitLine(value: string): string {
|
|
520
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
521
|
+
if (collapsed.length === 0) return EMPTY_COMMIT_LINE;
|
|
522
|
+
return collapsed.length > MAX_COMMIT_LINE
|
|
523
|
+
? `${collapsed.slice(0, MAX_COMMIT_LINE - 1)}…`
|
|
524
|
+
: collapsed;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Reduce an id to a git-ref-safe branch segment (no `..`, spaces, or `~^:?*[`). */
|
|
528
|
+
function sanitizeRef(id: string): string {
|
|
529
|
+
let cleaned = id
|
|
530
|
+
.trim()
|
|
531
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
532
|
+
.replace(/\.\.+/g, "-")
|
|
533
|
+
.replace(/^[-.]+|[-.]+$/g, "")
|
|
534
|
+
.slice(0, 60);
|
|
535
|
+
// A git ref component may not END in `.lock` (git reserves that suffix for its
|
|
536
|
+
// own lock files), so an id like `foo.lock` would otherwise yield a segment git
|
|
537
|
+
// rejects. Strip a trailing `.lock` and re-trim; loop so a pathological
|
|
538
|
+
// `foo.lock.lock` can't sneak one past a single pass.
|
|
539
|
+
while (/\.lock$/.test(cleaned)) {
|
|
540
|
+
cleaned = cleaned.replace(/\.lock$/, "").replace(/[-.]+$/g, "");
|
|
541
|
+
}
|
|
542
|
+
return cleaned === "" || cleaned === "." || cleaned === ".." ? "record" : cleaned;
|
|
543
|
+
}
|