@nanobpm/urban 0.68.1 → 0.69.1

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