fdeops 3.27.1 → 3.29.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.
@@ -0,0 +1,138 @@
1
+ 'use strict'
2
+
3
+ // Local pseudonyms, not anonymization. Recognized identifiers are replaced
4
+ // in output and proposals, and retained in a private reversible dictionary.
5
+ const fs = require('node:fs')
6
+ const path = require('node:path')
7
+ const crypto = require('node:crypto')
8
+ const { StringDecoder } = require('node:string_decoder')
9
+ const ALIAS = /\[\[(email|phone|identifier|credential):[a-f0-9]{16}\]\]/g
10
+ const PATTERNS = [
11
+ ['credential', /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|$)/g],
12
+ ['credential', /\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g],
13
+ ['credential', /\b[a-z][a-z0-9+.-]*:\/\/[^/\s:]+:[^/\s@]+@[^\s<>"']+/gi],
14
+ ['credential', /\bBearer\s+[A-Za-z0-9._-]{20,}/gi],
15
+ ['credential', /\b(?:api[_-]?key|secret|password)\s*=\s*[^\s"']{8,}/gi],
16
+ ['email', /(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?){1,10}/g],
17
+ ['identifier', /\b\d{3}-\d{2}-\d{4}\b/g],
18
+ // Deliberately conservative: international + notation or explicit US shape.
19
+ // Plain integers and dates are not reliably distinguishable from business data.
20
+ ['phone', /(?<![\w])(?:\+\d{1,3}[ .-]?(?:\(\d{2,4}\)|\d{2,4})(?:[ .-]?\d){6,10}|\(\d{3}\)[ .-]?\d{3}[ .-]\d{4}|\d{3}[ .-]\d{3}[ .-]\d{4})(?!\d)/g],
21
+ ]
22
+ const FAIL = 'privacy masking unavailable: check the local .privacy directory; no unmasked output was returned'
23
+ function replacements(text, replace) {
24
+ let out = String(text)
25
+ for (const [kind, pattern] of PATTERNS) out = out.replace(pattern, value => replace(kind, value))
26
+ return out
27
+ }
28
+ function createMasking(root) {
29
+ const directory = path.join(root, '.privacy'), file = path.join(directory, 'identifiers.json')
30
+ function directoryReady(create) {
31
+ if (create) fs.mkdirSync(root, { recursive: true })
32
+ if (create) { try { fs.mkdirSync(directory, { mode: 0o700 }) } catch (e) { if (e.code !== 'EEXIST') throw e } }
33
+ const st = fs.lstatSync(directory)
34
+ if (!st.isDirectory() || st.isSymbolicLink() || (process.platform !== 'win32' && (st.mode & 0o077))) throw new Error(FAIL)
35
+ }
36
+ function read(create) {
37
+ let fd
38
+ try {
39
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK)
40
+ const st = fs.fstatSync(fd)
41
+ if (!st.isFile() || st.nlink !== 1 || st.size > 8 * 1024 * 1024 || (process.platform !== 'win32' && (st.mode & 0o077))) throw new Error(FAIL)
42
+ const data = JSON.parse(fs.readFileSync(fd, 'utf8'))
43
+ if (data.version !== 1 || !Array.isArray(data.entries) || data.entries.length > 50000) throw new Error(FAIL)
44
+ const seen = new Set()
45
+ for (const entry of data.entries) {
46
+ if (!entry || typeof entry.value !== 'string' || !/^\[\[(email|phone|identifier|credential):[a-f0-9]{16}\]\]$/.test(entry.alias) || seen.has(entry.alias)) throw new Error(FAIL)
47
+ seen.add(entry.alias)
48
+ }
49
+ return data
50
+ } catch (e) { if (e.code === 'ENOENT' && create) return { version: 1, entries: [] }; throw e }
51
+ finally { if (fd !== undefined) fs.closeSync(fd) }
52
+ }
53
+ function transact(create, fn) {
54
+ let locked = false
55
+ const lock = path.join(directory, 'lock')
56
+ try {
57
+ directoryReady(create)
58
+ for (let attempt = 0; attempt < 100; attempt++) {
59
+ try { fs.mkdirSync(lock, { mode: 0o700 }); locked = true; break }
60
+ catch (e) { if (e.code !== 'EEXIST') throw e; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10) }
61
+ }
62
+ if (!locked) throw new Error(FAIL)
63
+ const data = read(create), before = data.entries.length, result = fn(data.entries)
64
+ if (data.entries.length !== before) {
65
+ if (data.entries.length > 50000) throw new Error(FAIL)
66
+ const body = JSON.stringify(data)
67
+ if (Buffer.byteLength(body) > 8 * 1024 * 1024) throw new Error(FAIL)
68
+ const tmp = path.join(directory, `.write-${crypto.randomBytes(12).toString('hex')}`)
69
+ try { fs.writeFileSync(tmp, body, { mode: 0o600, flag: 'wx' }); fs.renameSync(tmp, file) }
70
+ finally { try { fs.unlinkSync(tmp) } catch (_) {} }
71
+ }
72
+ return result
73
+ } catch (_) { throw new Error(FAIL) }
74
+ finally { if (locked) { try { fs.rmdirSync(lock) } catch (_) {} } }
75
+ }
76
+ function mask(text) {
77
+ text = String(text)
78
+ let detected = false
79
+ replacements(text, (_, value) => { detected = true; return value })
80
+ if (!detected) return text
81
+ return transact(true, entries => {
82
+ const values = new Map(entries.map(e => [e.value, e.alias]))
83
+ return replacements(text, (kind, value) => {
84
+ if (!values.has(value)) {
85
+ const alias = `[[${kind}:${crypto.randomBytes(8).toString('hex')}]]`
86
+ entries.push({ alias, value }); values.set(value, alias)
87
+ }
88
+ return values.get(value)
89
+ })
90
+ })
91
+ }
92
+ function restore(text) {
93
+ text = String(text)
94
+ if (/\[\[(email|phone|identifier|credential):/.test(text.replace(ALIAS, ''))) throw new Error(FAIL)
95
+ if (!text.match(ALIAS)) return text
96
+ return transact(false, entries => {
97
+ const aliases = new Map(entries.map(e => [e.alias, e.value]))
98
+ return text.replace(ALIAS, alias => {
99
+ if (!aliases.has(alias)) throw new Error(FAIL)
100
+ return aliases.get(alias)
101
+ })
102
+ })
103
+ }
104
+ return { mask, restore }
105
+ }
106
+
107
+ // CLI output is finite, not an interactive stream. Buffer to catch identifiers
108
+ // split across writes, including multiline credentials. Never fall back to raw.
109
+ function protectOutput(masking, { maxBytes } = {}) {
110
+ const buffers = ['', ''], decoders = [new StringDecoder('utf8'), new StringDecoder('utf8')]
111
+ let overflow = false
112
+ for (const [index, stream] of [process.stdout, process.stderr].entries()) {
113
+ stream.write = (chunk, encoding, callback) => {
114
+ const text = typeof chunk === 'string' ? chunk : decoders[index].write(chunk)
115
+ buffers[index] += text
116
+ if (Buffer.byteLength(buffers[index]) > 16 * 1024 * 1024) { overflow = true; buffers[index] = '' }
117
+ const cb = typeof encoding === 'function' ? encoding : callback
118
+ if (cb) cb()
119
+ return true
120
+ }
121
+ }
122
+ process.once('exit', () => {
123
+ try {
124
+ if (overflow) throw new Error(FAIL)
125
+ // Prepare both before emitting either, so a failure cannot expose raw data.
126
+ const output = buffers.map((text, i) => masking.mask(text + decoders[i].end()))
127
+ if (maxBytes && Buffer.byteLength(output[0]) > maxBytes) {
128
+ const notice = '\n[Masked context truncated; retrieve a narrower topic.]\n'
129
+ let body = Buffer.from(output[0]).subarray(0, maxBytes - Buffer.byteLength(notice)).toString('utf8').replace(/\uFFFD$/, '')
130
+ const open = body.lastIndexOf('[[')
131
+ if (open > body.lastIndexOf(']]')) body = body.slice(0, open)
132
+ output[0] = body + notice
133
+ }
134
+ output.forEach((text, i) => { if (text) fs.writeSync(i + 1, text) })
135
+ } catch (_) { process.exitCode = 1; fs.writeSync(2, FAIL + '\n') }
136
+ })
137
+ }
138
+ module.exports = { createMasking, protectOutput }
package/bin/lib/memory.js CHANGED
@@ -53,7 +53,7 @@ function createMemoryApi(deps) {
53
53
  execFileSync('git', ['add', '--', f], { cwd: eng, stdio: 'ignore', timeout: 10000 })
54
54
  }
55
55
  } else {
56
- execFileSync('git', ['add', '-A'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
56
+ execFileSync('git', ['add', '-A', '--', '.', ':(exclude).privacy'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
57
57
  }
58
58
  const porcelain = execFileSync('git', ['status', '--porcelain'], {
59
59
  cwd: eng, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'],
@@ -72,6 +72,9 @@ function createMemoryApi(deps) {
72
72
  })
73
73
  }
74
74
  }
75
+ // Even a previously staged alias dictionary must never enter a CLI commit.
76
+ const privateStaged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: eng, encoding: 'utf8', timeout: 10000 }).split('\n').some(f => f.split('/').includes('.privacy'))
77
+ if (privateStaged) throw new Error('private alias state must not be staged in engagement history')
75
78
  const still = execFileSync('git', ['diff', '--cached', '--name-only'], {
76
79
  cwd: eng, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'],
77
80
  }).toString().trim()
@@ -131,7 +134,7 @@ function createMemoryApi(deps) {
131
134
  execFileSync('git', ['init'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
132
135
  atomicWriteFile(
133
136
  path.join(eng, '.gitignore'),
134
- ['*.lock', '*.tmp', '.last-write', '.debrief-propose', '.debrief-private', '.debrief-seal', ''].join('\n')
137
+ ['*.lock', '*.tmp', '.last-write', '.debrief-propose', '.debrief-private', '.debrief-seal', '.privacy/', ''].join('\n')
135
138
  )
136
139
  const owner = writeOwnerIfMissing(eng)
137
140
  configureMemoryGitIdentity(eng, owner)
package/bin/lib/render.js CHANGED
@@ -374,7 +374,7 @@ function deliveryHtml(e) {
374
374
  return `<section class="fb-block fb-delivery" aria-label="Delivery evidence">
375
375
  <div class="fb-sec-row"><h2 class="fb-sec">Delivery evidence</h2><span class="fb-count">${rows.length} outcome${rows.length === 1 ? '' : 's'}</span></div>
376
376
  <p class="fb-evidence-note">Recorded in delivery.md. Acceptance reflects the saved record, not independent verification.</p>
377
- ${rows.length ? `<div class="fb-table-scroll" role="region" aria-label="Promised, measured and accepted outcomes" tabindex="0"><table class="fb-table fb-value-table"><thead><tr><th scope="col">Outcome</th><th scope="col">Promised</th><th scope="col">Measured</th><th scope="col">Acceptance</th><th scope="col">Evidence</th></tr></thead><tbody>${rows.map(r => `<tr><th scope="row">${inlineMd(r.slice || 'Outcome')}<span class="fb-value-state t-${r.state === 'accepted' ? 'green' : 'amber'}">${labels[r.state]}</span></th><td>${cell(r.promised, 'Not recorded')}</td><td>${cell(r.measured, 'Not yet measured')}</td><td>${cell(r.accepted, 'Not recorded')}</td><td>${cell(r.evidence, 'Not recorded')}</td></tr>`).join('')}</tbody></table></div>` : `<div class="fb-empty-evidence"><strong>No delivery evidence yet</strong><p>Ask your agent to define one useful increment: the expected result, how to measure it, and who will accept it.</p></div>`}
377
+ ${rows.length ? `<div class="fb-table-scroll" role="region" aria-label="Promised, measured and accepted outcomes" tabindex="0"><table class="fb-table fb-value-table"><thead><tr><th scope="col">Outcome</th><th scope="col">Promised</th><th scope="col">Measured</th><th scope="col">Acceptance</th><th scope="col">Evidence</th></tr></thead><tbody>${rows.map(r => `<tr><th scope="row">${inlineMd(r.slice || 'Outcome')}<span class="fb-value-state t-${r.state === 'accepted' ? 'green' : 'amber'}">${labels[r.state]}</span>${r.acceptanceIssue ? `<span class="fb-muted">${inlineMd(r.acceptanceIssue)}</span>` : ''}</th><td>${cell(r.promised, 'Not recorded')}</td><td>${cell(r.measured, 'Not yet measured')}</td><td>${cell(r.accepted, 'Not recorded')}</td><td>${cell(r.evidence, 'Not recorded')}</td></tr>`).join('')}</tbody></table></div>` : `<div class="fb-empty-evidence"><strong>No delivery evidence yet</strong><p>Ask your agent to define one useful increment: the expected result, how to measure it, and who will accept it.</p></div>`}
378
378
  </section>`
379
379
  }
380
380
 
package/bin/lib/trust.js CHANGED
@@ -3,7 +3,7 @@
3
3
  function createTrustApi(deps) {
4
4
  const {
5
5
  fs, path, readClean, readEng, parseMdTable, sectionBody, SIGNAL_LEDGER, memoryDirtyManual,
6
- stripTemplateNoise, stripLegendLines,
6
+ stripTemplateNoise, stripLegendLines, extractRisks,
7
7
  } = deps
8
8
 
9
9
  // phase / trust / top risk / freshness - identical heuristic for status + dashboard.
@@ -129,27 +129,7 @@ function createTrustApi(deps) {
129
129
  }
130
130
 
131
131
  function countOpenRisks(eng) {
132
- const md = readClean(eng, 'risks.md')
133
- const body = md.split(/^#{1,6}\s+Retired\b/im)[0] || md
134
- let n = 0
135
- for (const raw of body.split('\n')) {
136
- const t = raw.trim()
137
- if (!t || t.startsWith('<!--') || /^#{1,6}\s/.test(t)) continue
138
- if (/risk\s*\|\s*status|mitigation/i.test(t) || /^\|?[\s|:-]+$/.test(t)) continue
139
- // Bullet risk with substance (skip empty "- " stubs). The bullet marker
140
- // must be followed by space: "**Status:** open · closed" is a legend, and
141
- // counting it as a risk reported one open risk on an empty register.
142
- if (/^[-*]\s/.test(t)) {
143
- if (t.replace(/^[-*]\s+/, '').trim()) n++
144
- continue
145
- }
146
- // Table row: first cell must have risk text (day-1 "| | open | |" placeholders don't count).
147
- if (/^\|/.test(t) && t.length > 12) {
148
- const riskCell = t.split('|').map(c => c.trim())[1] || ''
149
- if (riskCell) n++
150
- }
151
- }
152
- return n
132
+ return extractRisks(eng).length
153
133
  }
154
134
 
155
135
  function nextActionLine(ctx) {
@@ -166,7 +146,7 @@ function createTrustApi(deps) {
166
146
  function computeSignals(eng) {
167
147
  // readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
168
148
  // to the terminal and the rendered HTML - a <private> risk must never surface.
169
- const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md'); const risks = readClean(eng, 'risks.md')
149
+ const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md')
170
150
  // Prefer structured tokens from stakeholders + CLI ledger (ledger survives wipes)
171
151
  const signalText = stake + '\n' + readClean(eng, SIGNAL_LEDGER)
172
152
  const phase = parsePhase(ctx)
@@ -215,11 +195,7 @@ function createTrustApi(deps) {
215
195
  trust = sLines.some(l => /\bred\b/i.test(l)) ? 'RED'
216
196
  : sLines.some(l => /amber|gone quiet|routing around|escalat/i.test(l)) ? 'amber' : 'new'
217
197
  }
218
- const topRisk = (risks.split('\n').find(l => {
219
- const t = l.trim()
220
- return /^[-|]/.test(t) && t.length > 20 && !/^\|?[-\s|]+$/.test(t) &&
221
- !/risk\s*\|\s*status|mitigation/i.test(t) && !t.startsWith('<!--')
222
- }) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
198
+ const topRisk = (extractRisks(eng)[0]?.text || '').replace(/\s+/g, ' ').trim().slice(0, 80)
223
199
  // Prefer trust trigger / memory warn over a random risk line; always keep mem.warn available
224
200
  const reason = (trustReason || mem.warn) ? (trustReason || mem.warn) : topRisk
225
201
  // What the triage line is actually quoting. A risk bullet printed under
@@ -26,6 +26,7 @@ function evidenceSource(evidence) {
26
26
  }
27
27
 
28
28
  function valueState({ measured, accepted, acceptanceStatus, evidence }) {
29
+ if (withdrawalMention({ measured, accepted, acceptanceStatus, evidence })) return 'claimed'
29
30
  if (!measured || PENDING_CELL_RE.test(measured)) return 'unmeasured'
30
31
  if (!acceptanceName(accepted) || !evidenceSource(evidence)) return 'claimed'
31
32
  // Explicit status is authoritative when the column exists. Unknown values
@@ -37,4 +38,48 @@ function valueState({ measured, accepted, acceptanceStatus, evidence }) {
37
38
  return 'accepted'
38
39
  }
39
40
 
40
- module.exports = { PENDING_CELL_RE, acceptanceName, evidenceSource, valueState }
41
+ // These checks flag explicit conflicts for human review. They do not authenticate
42
+ // approval, infer delegation, or revoke history when the current signer changes.
43
+ function withdrawalMention(row) {
44
+ return [row.measured, row.accepted, row.acceptanceStatus, row.evidence].filter(Boolean).some(value => {
45
+ const text = String(value)
46
+ .replace(/\[source:[^\]]*\]/gi, '')
47
+ .replace(/\S*[/\\]\S*/g, '') // artifact names are sources, not withdrawal events
48
+ .replace(/\b(?:not|never)\s+(?:been\s+)?(?:withdrawn|retracted|revoked|superseded)\b/gi, '')
49
+ // A withdrawn result is different from a result counting revoked tokens.
50
+ return /^(?:withdrawn|retracted|revoked|superseded)(?:\s*:|\s*$)/i.test(text.trim()) ||
51
+ /\b(?:approval|acceptance|evidence|measurement|result|assertion)\b[^.;\n]{0,40}\b(?:withdrawn|retracted|revoked|superseded)\b/i.test(text) ||
52
+ /\b(?:withdrawn|retracted|revoked|superseded|retracts?|withdraws?)\b[^.;\n]{0,25}\b(?:approval|acceptance|evidence|measurement|result|assertion)\b/i.test(text)
53
+ })
54
+ }
55
+
56
+ function scopeIssue(row, goal) {
57
+ const rowPromise = String(row.promised || '')
58
+ const goalLine = String(goal).match(/^(?:\*\*)?(?:Done when|Acceptance check):(?:\*\*)?\s*(.*)$/im)
59
+ const scopeSpecified = /\b(?:production|staging|synthetic|slides?|demo|prototype|poc)\b/i.test(rowPromise)
60
+ const promise = scopeSpecified ? rowPromise : `${rowPromise} ${goalLine ? goalLine[1] : goal}`
61
+ const acceptance = String(row.accepted || '')
62
+ const limited = acceptance.match(/\b(staging|slides?(?: design)?|demo|prototype|poc)\s+only\b/i)
63
+ if (limited) {
64
+ const scope = limited[1].toLowerCase()
65
+ const matchingGoal = scope.startsWith('slide') ? /\bslides?\b/i.test(promise) : new RegExp('\\b' + scope + '\\b', 'i').test(promise)
66
+ if (!matchingGoal || /\b(?:production|clinical use|go.live)\b/i.test(promise)) return 'approval scope is limited; review against the promised outcome'
67
+ }
68
+ const measured = String(row.measured || '')
69
+ const productionUntested = measured.split(/[;.\n]/).some(clause => /\bproduction\b/i.test(clause) && /\b(?:not|never|untested|unmeasured|pending)\b/i.test(clause))
70
+ if (/\bproduction\b/i.test(promise) && /\b(?:staging|synthetic|prototype|poc)\b/i.test(measured) && (!/\bproduction\b/i.test(measured) || productionUntested)) return 'measurement scope differs from production promise; review required'
71
+ return ''
72
+ }
73
+
74
+ function reconcileValueRows(rows, goal = '') {
75
+ const key = row => String(row.slice || '').trim().toLowerCase()
76
+ const withdrawn = new Set(rows.filter(withdrawalMention).map(key).filter(Boolean))
77
+ return rows.map(row => {
78
+ let acceptanceIssue = scopeIssue(row, goal)
79
+ if (withdrawalMention(row)) acceptanceIssue = 'withdrawal recorded; review current acceptance'
80
+ else if (withdrawn.has(key(row))) acceptanceIssue = 'conflicting withdrawal for this slice; review history before relying on acceptance'
81
+ return acceptanceIssue ? { ...row, acceptanceIssue, state: row.state === 'unmeasured' ? 'unmeasured' : 'claimed' } : row
82
+ })
83
+ }
84
+
85
+ module.exports = { PENDING_CELL_RE, acceptanceName, evidenceSource, valueState, reconcileValueRows }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops-ingest-mcp",
3
- "version": "3.27.1",
3
+ "version": "3.29.0",
4
4
  "private": true,
5
5
  "description": "Thin stdio MCP sink for FDEOps ingest (stage → propose → apply). Zero runtime dependencies.",
6
6
  "bin": {
@@ -10,6 +10,10 @@
10
10
 
11
11
  const fs = require('fs')
12
12
  const path = require('path')
13
+ const os = require('os')
14
+ const masking = require('../../bin/lib/masking').createMasking(
15
+ (process.env.FDEOPS_ENGAGEMENTS_ROOT || '').trim().replace(/^~/, os.homedir()) || path.join(os.homedir(), 'fde-engagements')
16
+ )
13
17
  const { spawnSync } = require('child_process')
14
18
 
15
19
  const PROTOCOL_VERSION = '2024-11-05'
@@ -171,7 +175,9 @@ function cliPayload(out) {
171
175
  }
172
176
 
173
177
  function toolResult(payload) {
174
- const text = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)
178
+ let text
179
+ try { text = masking.mask(typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)) }
180
+ catch (_) { return { isError: true, content: [{ type: 'text', text: 'privacy masking unavailable; no unmasked tool output returned' }] } }
175
181
  return { content: [{ type: 'text', text }] }
176
182
  }
177
183
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.27.1",
3
+ "version": "3.29.0",
4
4
  "description": "Client delivery tools for Forward Deployed Engineers. One @fde skill, local Markdown engagement records, and an offline dashboard for decisions, evidence, approvals, and next actions.",
5
5
  "bin": {
6
6
  "fdeops": "bin/install.js",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "fdeops",
4
- "version": "3.27.1",
4
+ "version": "3.29.0",
5
5
  "description": "Forward deployed engineering skills for AI coding agents. One @fde skill for the client work around the code. You confirm; then it lands in .fde/ on your laptop.",
6
6
  "author": {
7
7
  "name": "Subash Natarajan",
@@ -28,7 +28,7 @@ A one-line typo or compile error in a file that will not ship. On a bound client
28
28
  | **When did we agree?** | Don't argue from memory. Search the record. | `fde receipts <term>` | - |
29
29
  | **What's the outcome?** | A number nobody signed is claimed, not delivered. | `fde status` | `references/readout.md` |
30
30
 
31
- After a meeting: `fde debrief --smart` → one REVIEW screen (decisions / asks / scope / delivery gaps / next / signer) → in chat, a four-row card (omit empty; Previously / Not yet agreed) → **Save this update?** (engineer accepted the record, not customer approval of every ask) → `--apply`. Walk-in: `fde prep`. Friday: `fde status`.
31
+ After a meeting: the agent runs `fde debrief --smart`, interprets and reconciles the sanitized proposal, then validates it with `fde debrief --review`. Show the human one concise review of consequential changes and uncertainties → **Save this update?** → `--apply` only after confirmation → verify the saved facts. See `references/debrief.md` for the shared preparation contract. Walk-in: `fde prep`. Friday: `fde status`.
32
32
 
33
33
  ## Ground loop
34
34
 
@@ -69,7 +69,7 @@ Writes need a bind (`FDEOPS_ENGAGEMENT` or registry). Never install fdeops on in
69
69
  |----------|---------|
70
70
  | where are we | `fde resume` |
71
71
  | day-1 look at the repo | `fde scan` |
72
- | debrief / pasted notes | `fde debrief --smart` → REVIEW → four-row chat card → Save this update? → `--apply`. `--smart` is a gate, not a brain. `references/debrief.md` |
72
+ | debrief / pasted notes | `fde debrief --smart` → agent reconciliation → one plain-English review → Save this update? → `--apply`. `--smart` is a gate, not a brain. `references/debrief.md` |
73
73
  | prep me for … | `fde prep "<label>"` |
74
74
  | when did we agree | `fde receipts <term>` |
75
75
  | sponsor update / defend the number | `fde defend` |
@@ -211,3 +211,7 @@ Ready to build with no `terrain.md` / plan: discover or plan first. Takeover wit
211
211
  - Evidence on every claim. The FDE will be challenged on these files.
212
212
  - Overlays activate on signal, not on request.
213
213
  - Load `.fde/` files on demand, never the whole folder.
214
+
215
+ ## Identifier masking
216
+
217
+ Before reading engagement content in a session, run `fde privacy` to verify runtime support. If the command is unavailable, stop and update the CLI; a new skill alone does not upgrade an older executable. Use CLI context and previews for model input. They mask common email, phone, SSN-shaped, and credential patterns by default; aliases remain consistent within the local engagements root. Preserve complete alias tokens when drafting updates; the CLI resolves them locally. Never read the private `.privacy/` dictionary, sealed sidecars, raw sensitive notes, or local dashboard/vault files to recover an identity. Names, company names, addresses, and unrecognized formats are not automatically detected: keep sensitive prose in `<private>` blocks. Direct file tools, pasted chat, and upstream source MCPs bypass this boundary.
@@ -4,11 +4,11 @@
4
4
 
5
5
  **Read first:** `context.md` if it exists - otherwise start cold. The point of this phase is to establish ground truth, not assume it.
6
6
 
7
- ## Method - part 1: read everything that exists (you do this work)
7
+ ## Method - part 1: inspect the inherited record (you do this work)
8
8
 
9
9
  Before forming any opinion:
10
10
 
11
- 1. **Inherit the paper.** Any previous `.fde/`, docs, README claims, ADRs, ticket history the FDE can export. Read it all - the previous FDE's decisions are evidence, not verdicts.
11
+ 1. **Inherit the paper.** Start with `fde resume` and inventory the available docs, ADRs, ticket exports and operational handoff. Do not recursively load `.fde/` or raw transcripts. List the claims and unknowns, then use `fde recall <specific topic>` to retrieve bounded evidence for each consequential claim. Review the relevant source when an excerpt is insufficient; keep unrelated history on disk. Previous decisions are evidence, not verdicts.
12
12
  2. **Run the discover scans** (see `discover.md` part 1: churn, test gaps, "temporary" grep, AI components). On a takeover, add:
13
13
  ```bash
14
14
  git log --format="%an" | sort | uniq -c | sort -rn | head # who actually built this
@@ -55,7 +55,7 @@ Build without a plan in an inherited system is the fastest path to the second in
55
55
 
56
56
  ## Principles
57
57
 
58
- - Read everything that exists before forming any opinion.
58
+ - Inventory the record; verify consequential claims through targeted, bounded retrieval before forming an opinion.
59
59
  - "It should work" is not "it works." Verify.
60
60
  - The most dangerous systems are the ones everyone assumes someone else understands.
61
61
  - Don't build until `audit.md`, `terrain.md`, `reality.md` are written.
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Large transcripts or emails** sitting in Granola/Gmail/Notion → prefer **`fde ingest stage`** first (via source MCPs the FDE configured), then the same propose → confirm → **`fde ingest apply`** path. See `references/ingest.md`. Pasted short notes stay on this debrief verb.
6
6
 
7
- **Read first:** `context.md`, `stakeholders.md` (signals against what's known).
7
+ **Read first:** the bounded `fde resume` packet for the bound client. Use `fde recall` for the specific prior decision, action, or delivery result needed to reconcile this update. Do not reload the whole engagement.
8
8
 
9
9
  **Who runs the CLI:** you (the agent). Never tell the FDE to type `fde debrief …`.
10
10
 
@@ -13,7 +13,7 @@
13
13
  - The `fde` CLI is **local, deterministic, no AI**. `--smart` is a **gate + writer**, not a brain.
14
14
  - It keeps lines that already have `decision:` / `risk:` / `delivery:` / `contact:` / `next:` / `signer:` prefixes, plus a thin keyword pass (e.g. "we agreed", person+verb lines, "open question", "X signs off").
15
15
  - `signer: Priya` fills **Stakeholder who signs off** in `success.md` and logs Priya as a contact. The CLI proposes it when a sentence says someone signs off / approves / has final say. If the notes name who can say yes and the proposal does not carry a `signer:` line, add one - that is the most expensive sentence in the meeting.
16
- - Real messy notes without prefixes often route **0 useful lines** - everything else lands as a context dump. That is expected. **You are the router:** rewrite `.debrief-propose` with type prefixes, then `--apply`.
16
+ - Heuristics can miss facts **and mislabel prefixed lines**. You interpret every candidate against the sanitized source, not just unprefixed lines. Split distinct decisions, requests, actions, and results; keep uncertainty. The user reviews meaning, never prefix syntax.
17
17
  - `.debrief-propose` is raw lines only (no routing annotations). "Edit if mis-routed" means **rewrite the line with the right prefix**, not leave a comment in the file.
18
18
 
19
19
  ## Method (you do this work)
@@ -22,25 +22,24 @@
22
22
 
23
23
  1. Save the FDE's notes to a temp `.md` file in the workspace (or pipe stdin).
24
24
  2. Run `fde debrief --smart <notes.md>` (or `npx fdeops debrief --smart …`).
25
- 3. Open `.debrief-propose`. If lines lack type prefixes, **rewrite them** before showing the FDE, e.g.:
26
- - `decision: agreed chargebacks stay phase 2 - Priya`
27
- - `decision: freeze the API [approved: Priya 2026-09-08]` (optional; missing means unconfirmed)
28
- - `risk: legal may reopen scope if we slip the SOW date`
29
- - `contact: Priya pushed hard on Friday deck [signal:amber]`
30
- - `signer: Priya` (she can say yes; lands in `success.md`)
31
- - `next: send one-pager before Thursday 9am`
32
- - unprefixed lines stay context color only
33
- 4. Show the **REVIEW** block first (decided / asked / open / next / signer). That is the one screen to confirm. File routing stays underneath.
34
- 5. In **chat**, after that REVIEW, present a four-row card and omit empty rows:
35
- - Decided
36
- - Asked / open
37
- - Next
38
- - Signer
39
- Then a **Previously:** line from the record, and **Not yet agreed** for anything still proposed. Ask **Save this update?** Saving means the engineer accepted this as the engagement record, not that the customer approved every ask. Uncertainty stays visible.
40
- 6. On FDE confirm → run `fde debrief --apply`.
41
- 7. On reject → stop; ask what to change; do not apply. Do not rebuild or replace the CLI REVIEW engine.
42
-
43
- No invented names or quotes. If the propose looks wrong, fix prefixes with judgment then re-apply or use the fallback path.
25
+ 3. Run `fde debrief --review` before opening an existing proposal so legacy identifiers are masked. Open the proposal only after review succeeds. Never open a proposal containing manually inserted raw private blocks. Prepare the pending proposal using **Prepare one update** below. Read only the sanitized `.debrief-propose`, never the sealed private sidecars or raw private source. Preserve privacy markers, source metadata, and complete identifier aliases such as `[[email:...]]`. The CLI restores known aliases locally on apply. Never read `.privacy/` or try to recover an identity with file tools. If an alias is truncated, retrieve a narrower excerpt; never guess or edit the token.
26
+ 4. Run `fde debrief --review` after editing. Treat the CLI REVIEW and routing output as your validation, not a second presentation to the user. Resolve errors and replay warnings before asking for confirmation.
27
+ 5. Show **one** concise review in chat: name the client, then the consequential changes in plain English. Include decisions, requests still unagreed, actions, reported delivery, signer or contact changes, and unresolved conflicts when present. Show the previous value only where it changes the meaning. Omit empty categories and CLI routing details; do not impose a fixed four-row card that hides other changes. If the proposal is too large to show faithfully, split the review into explicit batches; never approve hidden changes.
28
+ 6. Ask **Save this update?** This confirms the engineer's record, not customer acceptance. On confirmation, apply precisely that proposal with `fde debrief --apply`. A material correction requires a revised review and renewed confirmation. On rejection, leave the proposal pending and do not apply.
29
+ 7. Verify the changed facts through bounded `fde resume` / targeted `fde recall`. If a fieldbook is part of the current task, regenerate it using the existing command and destination after the confirmed save; do not make the user run it. End with a brief saved/not-saved result and the next action, not another full summary.
30
+
31
+ ### Prepare one update (shared with ingest)
32
+
33
+ Do this work yourself before the human review:
34
+
35
+ - **Check meaning, not keywords.** “We settled on delaying the rewrite” is a decision; “Mara will request access” is an action, even if the heuristic calls it a contact. A wish or suggestion remains a request, not agreement. Do not infer authority, approval, a calendar date from an unanchored relative date, or production value from staging.
36
+ - **Keep facts traceable.** Preserve supplied source locators on each consequential fact, using `[source: ...]`. If only a local file or staged item exists, cite that actual locator as a note source, not a customer receipt. Do not invent a meeting date or speaker. A source label is not authenticated approval.
37
+ - **Reconcile only what changed.** Compare affected facts with the current record using targeted retrieval. Leave unchanged sourced statements out of an accidental re-import. Preserve earlier history; record changed or conflicting claims explicitly. If everything is already recorded, say so and leave the pending proposal unapplied. If it blocks a later capture, explain that no new facts were saved and ask permission to replace that pending review; use `--replace-proposal` with the new notes only after that authorization. Do not delete proposal files or private sidecars manually. Do not use `--allow-replay` without explicit approval of an intentional repeat.
38
+ - **Protect the current next action.** A late meeting note does not automatically supersede a newer action. Keep older actions as dated context unless their current priority is established; show a conflict when it needs a decision. Use exactly one physical `next:` line for the current action. If multiple current actions are explicitly agreed, include them on that same line separated by semicolons; the CLI retains only the last `next:` line. Keep other dated commitments in context. Do not silently discard other commitments.
39
+ - **Keep memory useful.** Retain consequential facts and indispensable context; remove chatter and repetition from the proposal, not from the source. Preserve the raw input outside `.fde/` (staged material stays in `.inbox/`). Never remove privacy placeholders or modify sealed sidecars. Ask only about a consequential ambiguity that cannot remain explicitly unknown.
40
+ - **Structure the result.** Use `decision:` / `risk:` / `delivery:` / `contact:` / `next:` / `signer:`. Preserve `ask:` / `scope:` as explicitly proposed context when appropriate. Prepare the seven-field delivery row yourself for a reported result (see below); unknown fields stay `pending`. The human should not have to fill out a ledger to capture a meeting.
41
+
42
+ Before showing the review, check that every consequential fact in the sanitized source is represented, already recorded, or explicitly unresolved. Check classified lines as carefully as unclassified ones. Nothing is saved simply because this preparation is complete.
44
43
 
45
44
  ### Fallback - you structure, then route
46
45
 
@@ -53,10 +52,10 @@ If `--smart` is unavailable or you already have clean prefixes:
53
52
  - **Risks** - new / confirmed / retired
54
53
  - **Open questions** - what to chase next
55
54
  2. Format lines as `decision:` / `risk:` / `delivery:` / `contact:` / `next:` / `signer:` (contacts may end with `[signal:green|amber|red]`).
56
- 3. Show the same **chat card** as the smart path (omit empty rows; Previously; Not yet agreed; **Save this update?**). Do not invent a second confirm surface.
55
+ 3. Follow **Prepare one update** and show the same single plain-English review as the preferred path. Include every consequential change and ask **Save this update?**.
57
56
  4. On confirm, pipe to `fde debrief` (or write a file and run it).
58
57
 
59
- One clarifying question max if the dump is ambiguous - then write. Never stall capture on completeness.
58
+ Ask at most one focused question at a time when ambiguity would change the record. Otherwise preserve the unknown and include it in the review. Never treat silence as confirmation.
60
59
 
61
60
  ## Artifact
62
61
 
@@ -66,7 +65,7 @@ One clarifying question max if the dump is ambiguous - then write. Never stall c
66
65
 
67
66
  ## Checkpoint
68
67
 
69
- Read back the 2-3 most consequential captures in one breath - so the FDE can correct on the spot. Then stop. No summary theatre.
68
+ Use the single pre-save review above. After saving, report verification and the next action briefly; do not ask for a second approval or repeat the review.
70
69
 
71
70
  ## Principles
72
71
 
@@ -74,3 +73,15 @@ Read back the 2-3 most consequential captures in one breath - so the FDE can cor
74
73
  - Verbatim quote outranks paraphrase; hesitation outranks quote.
75
74
  - Signals move on evidence, never on vibe alone.
76
75
  - A meeting with no decisions and no actions - say so; that is a finding.
76
+
77
+ ## Delivery rows and repeated updates
78
+
79
+ For a measured or promised slice, use a reviewed structured line:
80
+
81
+ ```text
82
+ delivery: Replay|risk-mitigation|zero duplicates|zero duplicates on staging|pending|[source: transcript:42]|pending
83
+ ```
84
+
85
+ The seven fields are Slice, Bucket, Promised, Measured, Accepted by, Evidence, Rollback. Keep unknowns `pending`; never infer approval. This lands in the value ledger during the same confirmed apply. A `delivery:` line without pipes stays a narrative note. Incorrect field counts refuse the write rather than shifting the meaning of cells.
86
+
87
+ A sourced statement already in the record triggers a replay warning. Before applying, compare newer facts and the current next action. Remove repeated statements from the proposal if this is an accidental re-import. Only after the engineer explicitly confirms an intentional repeat, apply with `fde debrief --apply --allow-replay`. This does not silently deduplicate history and does not authenticate sources.
@@ -108,7 +108,7 @@ Flag every one. AI components don't fail like regular code - they degrade as the
108
108
 
109
109
  **6. Data flow.** Where data enters, how it moves, where it stops. Entry points first: routes, queues, cron, file drops.
110
110
 
111
- **7. Existing capability.** Trace the requested user action through existing code, configuration, tests, and operating workarounds. In `terrain.md`, record what can already be reused and the evidence that it works or fails. Check whether a configuration, ownership, or process change could resolve the observed break. A disabled feature is a lead, not a proven root cause. Keep observations and hypotheses distinct; option selection still belongs to plan / three-options.
111
+ **7. Existing capability.** Trace the requested user action through existing code, configuration, tests, and operating workarounds. In `terrain.md`, record what can already be reused and the evidence that it works or fails. Check whether a configuration, ownership, or process change could resolve the observed break. A disabled feature is a lead, not a proven root cause. Keep observations and hypotheses distinct; option selection still belongs to plan / three-options. Summarize the remaining gap in `reality.md`: what works today → what the customer needs → what is still missing, with sources. If existing capability meets the need, say so; do not manufacture a build requirement.
112
112
 
113
113
  ## Method - part 2: the humans (you coach, the FDE asks)
114
114
 
@@ -150,7 +150,7 @@ Always map the estate before you score a use case - not only when someone said "
150
150
  **The 5 questions (ask the data owner, not the sponsor):**
151
151
  1. **Where does data live?** - List every source: databases, warehouses, SaaS exports, spreadsheets, S3 buckets, vendor APIs. Map it.
152
152
  2. **How fresh is it?** - Real-time, daily batch, "someone uploads a CSV on Mondays"? Freshness determines what's buildable.
153
- 3. **Who owns it?** - Not "IT" - the named person who can grant access and explain the schema. No name = no access in practice.
153
+ 3. **Who owns it?** - Not "IT" - the named person who can grant access and explain the schema. No named owner: access responsibility remains unverified.
154
154
  4. **What's the quality?** - Sample 100 rows from each critical source. Check: nulls, duplicates, format consistency, semantic correctness. A 60% null rate in a key field = that source is fiction.
155
155
  5. **What are the governance constraints?** - PII classification, retention policies, cross-border rules, consent basis. One missed constraint = a compliance stop later.
156
156
 
@@ -164,6 +164,10 @@ Always map the estate before you score a use case - not only when someone said "
164
164
 
165
165
  A use case that depends on a "Blocker" source **or a Blocker pipe** doesn't get scored - it gets a remediation conversation first. `what-breaks` finding an invisible integration at ship is already too late. Write this to `terrain.md` under a `## Data estate` section.
166
166
 
167
+ **Promised dependencies are not ready dependencies.** For consequential promises such as "data in two weeks," record or update one dependency entry in `assumptions.md` with the responsible owner, dated verification checkpoint, and evidence needed. Unknown owners or dates stay unknown; propose a checkpoint for confirmation. Link the affected work; if the checkpoint slips, identify what can proceed and what needs replanning. Missing ownership is an unresolved dependency, not proof that the project will fail. On-prem or restricted access is a constraint to investigate, not a red flag by itself.
168
+
169
+ **Verify the future operator now.** Check the proposed owner in `success.md` against who will actually monitor, recover, and support the result. Record whether they have agreed, access or training gaps, and a practical handoff check there; carry these into `handoff.md` at close. Keep unconfirmed ownership explicit. Reuse supplied evidence and ask only what changes the plan.
170
+
167
171
  ## When scope is a transformation, not a single problem
168
172
 
169
173
  Score every candidate use case before anything gets prototyped:
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Connect / capability (different entry):** "connect a new MCP", "connect Granola/Slack/Notion", "what can you pull?" → `references/connect.md` first. Recipes: `mcp/recipes/` (file, granola, slack, notion).
6
6
 
7
- **Read first:** `context.md` (what's already logged, what's stale). Bind the engagement before staging anything.
7
+ **Read first:** the bounded `fde resume` packet and targeted recall for affected prior facts. Bind the engagement before staging anything.
8
8
 
9
9
  **Who runs the CLI:** you (the agent). Never tell the FDE to type `fde ingest …`. Never auto-apply. Never background-sync or poll sources on your own.
10
10
 
@@ -31,9 +31,9 @@ List what you can actually call **this session**:
31
31
  3. **Stage** - `fde ingest stage [--source NAME] [--title TEXT] [file|-]` writes raw text into `<engagement>/.inbox/` (outside the memory git ledger).
32
32
  4. **List** (optional) - `fde ingest list` shows staged items when you need an id or filename.
33
33
  5. **Propose** - `fde ingest propose <id-or-filename>` runs the debrief `--smart` path on the staged body (+ provenance line). Opens `.debrief-propose`.
34
- 6. **Rewrite prefixes** - same as debrief: lines without `decision:` / `risk:` / `delivery:` / `contact:` / `next:` / `signer:` need **you** to rewrite before showing the FDE. `--smart` is a gate, not a brain.
35
- 7. **Show** the same chat card as debrief (decided / asked / open / next / signer; omit empty; Previously; Not yet agreed; **Save this update?**). Wait for confirm. The CLI REVIEW printout is unchanged.
36
- 8. **Apply** - on FDE confirm only → `fde ingest apply` (= `fde debrief --apply`). On reject → stop; ask what to change.
34
+ 6. **Prepare** - follow **Prepare one update** in `references/debrief.md`. Interpret every sanitized candidate, including already-prefixed lines; reconcile changed facts, preserve source locators, and keep raw chatter out of memory. Preserve privacy placeholders and sealed sidecars.
35
+ 7. **Validate and show** - run `fde debrief --review` after editing, then show the same single plain-English review as debrief, including delivery changes and conflicts. Ask **Save this update?** and wait for confirmation. CLI output is agent validation, not a second user review.
36
+ 8. **Apply and verify** - on FDE confirm only → `fde ingest apply` (= `fde debrief --apply`), then verify affected facts through bounded resume/recall. Refresh the current fieldbook if it is part of this task. On reject, leave the proposal pending; material edits require a revised review.
37
37
 
38
38
  No invented names, meetings, or quotes. If the propose looks wrong, fix prefixes with judgment, then re-show before apply.
39
39
 
@@ -56,7 +56,7 @@ fde ingest apply
56
56
 
57
57
  ## Provenance
58
58
 
59
- When a staged fact came from a named source, carry `via:<source>` on the applied line where useful (e.g. `via:granola`, `via:gmail`). Helps receipts and sponsor disputes later - not mandatory on every context line.
59
+ Carry an actual `[source: ...]` locator on each consequential fact. Preserve upstream IDs or links when supplied; otherwise cite the staged item path as a note source. Retain its `via:` metadata, but do not treat a standalone `via:` line as a source marker for every fact or as proof of approval. Re-imports still require semantic comparison; exact replay protection is not semantic deduplication.
60
60
 
61
61
  ## MCP sink + recipes
62
62
 
@@ -64,7 +64,7 @@ Optional `mcp/fdeops-ingest` wraps the same verbs over stdio. Source MCPs remain
64
64
 
65
65
  ## Checkpoint
66
66
 
67
- Before apply, read back the 2-3 most consequential captures in one breath - same as debrief. Confirm which sources you staged and what would land in the record. Then stop.
67
+ Use the single review from debrief: identify the client and sources, show consequential changes, then wait. Do not add another summary or approval step.
68
68
 
69
69
  ## Principles
70
70
 
@@ -57,6 +57,7 @@ Intent: coach the FDE's first *customer* conversation - what keeps the sponsor u
57
57
  - "Before you open the laptop - what would make this a bad engagement for *them*, not just a delayed project?"
58
58
  - "What are they afraid you'll miss?"
59
59
  - "Who loses credibility if this goes wrong?"
60
+ - "If nothing changes over the agreed timeframe, what happens, and who bears it?" Record the consequence and its source in `brief.md`; distinguish reported impact from measured cost. Unknown cost stays unknown, not an invented ROI.
60
61
 
61
62
  Let silence sit. If their fear doesn't match the written brief, the brief is wrong - say so plainly, log it.
62
63
 
@@ -67,6 +68,7 @@ Let silence sit. If their fear doesn't match the written brief, the brief is wro
67
68
  - **The sacred thing** - "Is there anything in this environment I should treat as untouchable?" The hesitation before the answer is the answer.
68
69
  - **Exception path (operating map seed)** - "When the happy path breaks this week, what do people actually do - who do they call, what spreadsheet opens, what do they skip?" Capture the break → workaround → who owns it. Do not build a full map on day 1; seed rows later in `terrain.md` → `## Operating map (exception-led)` during discover. Unknowns stay `unknown - ask:`.
69
70
  - **AI posture and policy** - tools already in use (sanctioned or shadow), and: "Does your organisation have a policy on AI-generated code? Are there decisions where you would not be comfortable with AI involvement?"
71
+ - **Future operator** - "Who will run this after we leave, and have they agreed?" Record the proposed operator and unresolved ownership in `success.md`, separately from the signer. A sponsor naming a team is not that team accepting responsibility; verify with the operator during discover.
70
72
  - **Boundaries in multi-vendor rooms** - who owns what surface, who signs off before a change crosses it.
71
73
 
72
74
  ## The day 1 deliverable