@tekyzinc/gsd-t 5.5.10 → 5.6.10
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/CHANGELOG.md +41 -0
- package/README.md +1 -1
- package/bin/gsd-t-env-registry-check.cjs +304 -17
- package/bin/gsd-t-env-registry.cjs +6 -0
- package/bin/gsd-t-pseudocode-style.cjs +64 -2
- package/package.json +1 -1
- package/templates/CLAUDE-global.md +2 -0
- package/templates/infrastructure.md +26 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [5.6.10] - 2026-08-02
|
|
6
|
+
|
|
7
|
+
### Changed — the Environment Registry now fires (M103)
|
|
8
|
+
|
|
9
|
+
The registry shipped in M102 was never filled in — empty in 31 of 33 projects — so every session re-asked the human how to reach production. Four independent breaks, each verified:
|
|
10
|
+
|
|
11
|
+
1. **Record-at-create was prose, not code.** `recordEnvironment` had zero production callers; the instruction to record a row lived only in a markdown command file. Provisioning also often has no discrete moment to fire on — one project's production database came up via a hosting-platform integration during a deploy.
|
|
12
|
+
2. **Nothing backfilled** projects that predate M102.
|
|
13
|
+
3. **The empty state was certified as fine.** The gate looked for the env-access rule in the *project's* `CLAUDE.md`, but that rule ships in the *global* one — so every project took the "has not adopted the registry" no-op PASS branch.
|
|
14
|
+
4. **The nine-cycle secret grammar rejected TRUE values** (`n/a`, `cli-session`, lowercase `yes`, plain-English notes). The observed consequence was writing something *false* to get past it — a staging row recorded as production.
|
|
15
|
+
|
|
16
|
+
Changes:
|
|
17
|
+
|
|
18
|
+
- `bin/gsd-t-env-registry-check.cjs`: accept true values in columns that structurally cannot carry a secret (`n/a` in port / db-name / env-var-NAME; unenumerated sign-in methods in label shape; `yes`/`no` in any capitalisation; word-by-word prose in `access gotchas`). Password-carrying columns — host, db name, both commands — are untouched. A project with a remote environment and no prod/staging row now FAILS; a local-only project PASSES and is named `localOnly:true`. The M102 rule-vs-table condition is removed: once the rule is read from the global file it fired in every project (21 of 28, all correct local-only states) and added no signal the remote-environment check does not already carry.
|
|
19
|
+
- `bin/gsd-t-env-registry.cjs`: new `source` column. A vendor resource id is shape-identical to a token, so the row names where the value came from (`neonctl projects list`, `.vercel/project.json`) and the source vouches for it. Its presence is the flag — there is no per-vendor "source required" list to maintain, and therefore none to forget. A source never vouches past the backstop.
|
|
20
|
+
- `templates/infrastructure.md`, `templates/CLAUDE-global.md`: the 15-column schema, the relaxed column rules, and a write-the-row-before-you-use-the-answer rule.
|
|
21
|
+
- `.gsd-t/contracts/env-registry-contract.md`: v1.1.0 DRAFT → **v1.2.0 STABLE**, plus four `[RULE]`s — truth-accepted, source-vouches, empty-is-not-pass, local-only-named.
|
|
22
|
+
- `test/m102-env-registry.test.js`: 274 → 305 tests. Includes the leak found *during* this work — once `source` took the 15th slot the overflow-corruption guard no longer covered it, and a bare word passed an early "looks like a path" rule.
|
|
23
|
+
|
|
24
|
+
Two things were built and then reverted, both killed by evidence: a "recorded command must run as written" gate check (not a secret question, it failed provably-safe rows, and its rule only holds for a multi-project account — three existing tests caught it), and a rule that guessed resource ids by their spelling (replaced by the source column).
|
|
25
|
+
|
|
26
|
+
Measured across the project registry: **22 PASS / 6 FAIL, all six true findings** — five genuinely unmapped remote projects and one malformed row.
|
|
27
|
+
|
|
28
|
+
Suite 3083/0/13-skip.
|
|
29
|
+
|
|
30
|
+
## [5.5.11] - 2026-07-29
|
|
31
|
+
|
|
32
|
+
### Fixed — the new style gate would have failed 27 untouched documents in three projects
|
|
33
|
+
|
|
34
|
+
Caught by running the gate live in a downstream project immediately after propagation rather than trusting the "copied 1 bin tool(s)" report. v5.5.10 shipped the gate to all 33 registered projects, but the grandfather list that exempts pre-v1.2.0 documents existed only in the GSD-T repo. binvoice (19 documents), NiceNote (6) and IssueRecorder (2) had none — so the next `gsd-t-verify` run in those projects would have failed on documents nobody touched, written to a then-valid standard.
|
|
35
|
+
|
|
36
|
+
The gate now establishes its own starting line: on the `--dir` path, when the list file is absent and the directory already holds documents, it writes the list once from the existing set and reports `seeded: { reason: "seeded-pre-existing", count, docs }`.
|
|
37
|
+
|
|
38
|
+
This is **not a fallback** — it masks no failure and continues past none; it defines where the gate begins. Three properties keep it honest: written **once** (a self-re-seeding list would silently absolve every newly-drifted document, which is the banned behavior), keyed on the file being absent, and always surfaced naming every document covered. A document created after the seed is gated normally; a single `--doc` call never seeds; an un-writable directory returns exit 64 with the reason rather than a silent mass-pass or mass-fail.
|
|
39
|
+
|
|
40
|
+
- `bin/gsd-t-pseudocode-style.cjs`: `seedGrandfatherList()` + envelope `seeded` field.
|
|
41
|
+
- `test/pseudocode-style-gate.test.js`: 4 new tests (15 → 19) — seed happens, seed never re-runs, `--doc` never seeds, un-writable directory exits 64.
|
|
42
|
+
- `.gsd-t/contracts/pseudocode-source-of-truth-contract.md`: §1.1.5 documents the seed and why it is not a fallback.
|
|
43
|
+
|
|
44
|
+
Verified live: binvoice's 19 documents now report `exitCode 0`, 19 seeded, 0 violations. Suite 3052/0/13-skip.
|
|
45
|
+
|
|
5
46
|
## [5.5.10] - 2026-07-29
|
|
6
47
|
|
|
7
48
|
### Added — PseudoCode style is governed: the flow IS the document
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GSD-T: Contract-Driven Development for Claude Code
|
|
2
2
|
|
|
3
|
-
**v5.
|
|
3
|
+
**v5.6.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
|
|
4
4
|
|
|
5
5
|
**Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
|
|
6
6
|
**Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
// by construction) would still trip these.
|
|
37
37
|
|
|
38
38
|
const fs = require("fs");
|
|
39
|
+
const os = require("os");
|
|
39
40
|
const path = require("path");
|
|
40
41
|
|
|
41
42
|
const {
|
|
@@ -81,6 +82,22 @@ const GATE_VAULTS = new Set([
|
|
|
81
82
|
"hashicorp-vault", "vault", "azure-key-vault", "infisical",
|
|
82
83
|
]);
|
|
83
84
|
const GATE_GOTCHA_ENUM = new Set(["vpn", "ip-allowlist", "ssh-tunnel", "bastion", "none"]);
|
|
85
|
+
// M103 — "this column does not apply to this kind of environment" is a TRUE
|
|
86
|
+
// answer, not a hidden value. Accepted ONLY in columns that cannot carry a
|
|
87
|
+
// secret anyway (port, secret env-var NAME).
|
|
88
|
+
const GATE_NOT_APPLICABLE = new Set(["n/a", "na", "none", "—", "-"]);
|
|
89
|
+
// M103 — an auth METHOD the enum has not heard of (`cli-session`,
|
|
90
|
+
// `device-code`, `browser-session`). Accepted only in LABEL shape: 1-3 short
|
|
91
|
+
// lowercase alphabetic words joined by hyphens, ≤24 chars, NO digits. A
|
|
92
|
+
// credential fails this: tokens/keys/passwords carry digits, mixed case, or
|
|
93
|
+
// punctuation, and random lowercase-only strings exceed the per-word length.
|
|
94
|
+
const GATE_AUTH_LABEL = /^[a-z]{2,12}(?:-[a-z]{2,12}){0,2}$/;
|
|
95
|
+
function gateIsAuthLabelShape(s) {
|
|
96
|
+
if (typeof s !== "string") return false;
|
|
97
|
+
const v = s.trim();
|
|
98
|
+
if (v.length > 24) return false;
|
|
99
|
+
return GATE_AUTH_LABEL.test(v);
|
|
100
|
+
}
|
|
84
101
|
const GATE_CLI_WORDS = new Set([
|
|
85
102
|
"psql", "mysql", "mysqldump", "mongo", "mongosh", "redis-cli", "sqlite3",
|
|
86
103
|
"pg_dump", "pg_restore", "pg_dumpall", "cqlsh", "clickhouse-client",
|
|
@@ -91,6 +108,10 @@ const GATE_CLI_WORDS = new Set([
|
|
|
91
108
|
"env", "pull", "push", "list", "get", "set", "secrets", "versions",
|
|
92
109
|
"access", "version", "exec", "run", "connect", "login", "logout",
|
|
93
110
|
"connection-string", "db-url", "database-url", "redis-url",
|
|
111
|
+
// M103 — resource-listing subcommands, the shape of a `source` value
|
|
112
|
+
// ("neonctl projects list"). Ordinary CLI nouns, same class as list/get/show.
|
|
113
|
+
"projects", "project", "branches", "branch", "orgs", "org", "databases",
|
|
114
|
+
"roles", "endpoints", "instances", "buckets", "services", "apps",
|
|
94
115
|
"admin", "default", "latest", "read", "write", "describe", "show",
|
|
95
116
|
"from", "cat", "source", "printenv", "dotenv",
|
|
96
117
|
]);
|
|
@@ -234,6 +255,46 @@ function gateCommandOk(cell) {
|
|
|
234
255
|
}
|
|
235
256
|
return true;
|
|
236
257
|
}
|
|
258
|
+
// M103 — the access-gotchas column is the ONE cell that must carry human
|
|
259
|
+
// judgment in plain English ("live seller data — never mutate without an OK").
|
|
260
|
+
// That warning is the most valuable thing in the row: it is what stops a
|
|
261
|
+
// destructive mistake, and no vendor CLI can ever return it. The M102 enum
|
|
262
|
+
// rejected all prose, so the column was written as `none` and the judgment was
|
|
263
|
+
// lost.
|
|
264
|
+
//
|
|
265
|
+
// Prose is allowed here, but NOT as a hole in the guard. A word is accepted
|
|
266
|
+
// only if it is PROSE-SHAPED — and a secret is not prose-shaped. The test is
|
|
267
|
+
// per-WORD and structural (never a denylist of secret values, which the M102
|
|
268
|
+
// cycles proved unwinnable): a word must be ordinary letters, or ordinary
|
|
269
|
+
// punctuation, or one of the shapes already proven safe elsewhere in the row.
|
|
270
|
+
//
|
|
271
|
+
// What a prose word may be:
|
|
272
|
+
// - a letters-only word, with optional internal apostrophe/hyphen, ≤24 chars
|
|
273
|
+
// (`Marla's`, `never`, `read-only`, `VPN`) — no digits, so a random
|
|
274
|
+
// credential cannot pass as a word
|
|
275
|
+
// - a pure number / ordinary punctuation (`6h`, `17`, `—`, `.`, `(main)`)
|
|
276
|
+
// - a shape already whitelisted for other columns (host, $VAR, .env dotfile,
|
|
277
|
+
// curated CLI word, the gotcha enum)
|
|
278
|
+
// Everything else — any mixed letters+digits token that is not a plain unit
|
|
279
|
+
// like `6h`, anything with credential punctuation — FAILS. That is the shape
|
|
280
|
+
// a real secret has, and it still fails here exactly as before.
|
|
281
|
+
const GATE_PROSE_WORD = /^[A-Za-z]+(?:['’-][A-Za-z]+)*$/;
|
|
282
|
+
// A plain magnitude: 17, 6h, 30d, 5432. Digits with an optional short unit.
|
|
283
|
+
const GATE_PROSE_NUMBER = /^\d+[a-z]{0,2}$/i;
|
|
284
|
+
// Ordinary sentence punctuation carrying no value.
|
|
285
|
+
const GATE_PROSE_PUNCT = /^[.,;:!?()[\]{}"'’“”—–\-/&+%]+$/;
|
|
286
|
+
|
|
287
|
+
function gateIsProseWord(raw) {
|
|
288
|
+
// Strip surrounding punctuation so `(main),` tests as `main`.
|
|
289
|
+
const w = raw.replace(/^[.,;:!?()[\]{}"'’“”—–]+/, "").replace(/[.,;:!?()[\]{}"'’“”—–]+$/, "");
|
|
290
|
+
if (w === "") return true;
|
|
291
|
+
if (w.length > 24) return false;
|
|
292
|
+
if (GATE_PROSE_WORD.test(w)) return true;
|
|
293
|
+
if (GATE_PROSE_NUMBER.test(w)) return true;
|
|
294
|
+
if (GATE_PROSE_PUNCT.test(w)) return true;
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
237
298
|
function gateGotchasOk(cell) {
|
|
238
299
|
const tokens = cell.split(/[,\s]+/).filter(Boolean);
|
|
239
300
|
for (let i = 0; i < tokens.length; i++) {
|
|
@@ -246,11 +307,77 @@ function gateGotchasOk(cell) {
|
|
|
246
307
|
continue;
|
|
247
308
|
}
|
|
248
309
|
if (gateIsHostShape(tokens[i])) continue;
|
|
310
|
+
// M103 — plain-English judgment is allowed, word by word.
|
|
311
|
+
if (gateIsSafeNonSecretToken(tokens[i].replace(/^["']/, "").replace(/["']$/, ""))) continue;
|
|
312
|
+
if (gateIsProseWord(tokens[i])) continue;
|
|
249
313
|
return false;
|
|
250
314
|
}
|
|
251
315
|
return true;
|
|
252
316
|
}
|
|
253
317
|
|
|
318
|
+
// ─── M103 — the `source` column: where an unprovable value came from ─────────
|
|
319
|
+
//
|
|
320
|
+
// A vendor's resource id (`winter-frog-54927244`, `prj_3OQ3gUB1zkm5uraf…`) is
|
|
321
|
+
// indistinguishable from a token BY SHAPE — that is a fact about the values,
|
|
322
|
+
// not a gap in the grammar, so no amount of pattern-tightening resolves it.
|
|
323
|
+
// The M102 answer was to reject them, which forced a worse outcome: rows were
|
|
324
|
+
// written with FALSE values to get past the checker (a `staging` environment
|
|
325
|
+
// recorded as `prod`, a real command replaced by an unrelated one that
|
|
326
|
+
// happened to pass). A checker that makes the truth unwritable buys nothing.
|
|
327
|
+
//
|
|
328
|
+
// So a value the shape-grammar cannot vouch for is accepted when the row NAMES
|
|
329
|
+
// WHERE IT CAME FROM. The source's PRESENCE is the flag — there is no
|
|
330
|
+
// per-vendor "source required" list to add to, and therefore none to forget.
|
|
331
|
+
//
|
|
332
|
+
// What this does and does not defend against: it stops an ACCIDENT (a secret
|
|
333
|
+
// pasted into the wrong cell, a connection string carrying its password) —
|
|
334
|
+
// which is the whole M102 threat model. It does not stop someone deliberately
|
|
335
|
+
// writing a secret and inventing a source for it; nothing mechanical does, and
|
|
336
|
+
// that was never in scope.
|
|
337
|
+
//
|
|
338
|
+
// The source must itself be checkable — a command from the curated CLI words
|
|
339
|
+
// or a path to a file in the project. Free prose here would re-open the hole.
|
|
340
|
+
// A project-relative file path. It must LOOK like a path — carry a separator or
|
|
341
|
+
// a file extension. A bare word (`hunter2`) is NOT a path: allowing one would
|
|
342
|
+
// turn this column into the free-text cell the 15-column schema used to catch
|
|
343
|
+
// as overflow corruption (a real leak, found by the CYCLE5 overflow tests when
|
|
344
|
+
// the `source` column took over the 15th slot).
|
|
345
|
+
const GATE_SOURCE_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
|
|
346
|
+
const GATE_SOURCE_PATH_EVIDENCE = /[/]|^\.[A-Za-z]|\.[A-Za-z0-9]{1,8}$/;
|
|
347
|
+
|
|
348
|
+
function gateIsSourceShape(s) {
|
|
349
|
+
if (typeof s !== "string") return false;
|
|
350
|
+
const v = s.trim();
|
|
351
|
+
if (v === "" || v === "—") return true;
|
|
352
|
+
if (v.length > 120) return false;
|
|
353
|
+
// A command: every token must clear the same grammar the command columns use.
|
|
354
|
+
if (gateCommandOk(v)) return true;
|
|
355
|
+
// A file path in the project (.vercel/project.json, fly.toml, .env.example).
|
|
356
|
+
// Rejected if absolute, if it climbs out of the project, or if it is a bare
|
|
357
|
+
// word carrying no evidence of being a path at all.
|
|
358
|
+
if (
|
|
359
|
+
GATE_SOURCE_PATH.test(v) &&
|
|
360
|
+
!v.startsWith("/") &&
|
|
361
|
+
!v.includes("..") &&
|
|
362
|
+
GATE_SOURCE_PATH_EVIDENCE.test(v)
|
|
363
|
+
) {
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Does this row name a source? If so, cells the shape-grammar cannot prove are
|
|
370
|
+
// accepted — EXCEPT cells that hit the backstop, which stays absolute (see
|
|
371
|
+
// cellLeaks). A source vouches for an unrecognised value; it never vouches for
|
|
372
|
+
// something that positively looks like a credential.
|
|
373
|
+
function rowNamesASource(cells) {
|
|
374
|
+
const idx = ENV_COLUMNS.indexOf("source");
|
|
375
|
+
if (idx === -1) return false;
|
|
376
|
+
const v = (cells[idx] || "").trim();
|
|
377
|
+
if (v === "" || v === "—") return false;
|
|
378
|
+
return gateIsSourceShape(v);
|
|
379
|
+
}
|
|
380
|
+
|
|
254
381
|
// The BACKSTOP leak test — the known-prefix/JWT/base64/hex detector + the
|
|
255
382
|
// embedded-cred regex. Applied to EVERY cell as an extra layer.
|
|
256
383
|
function cellHitsBackstop(cell) {
|
|
@@ -278,24 +405,42 @@ function cellMatchesColumnShape(col, cell) {
|
|
|
278
405
|
case "host":
|
|
279
406
|
return gateIsHostShape(s.replace(/:\d+$/, ""));
|
|
280
407
|
case "port":
|
|
281
|
-
|
|
408
|
+
// M103: "not applicable" is a TRUE answer for a kind with no port (a
|
|
409
|
+
// web-console / CLI-session environment). Digits or n/a — nothing else.
|
|
410
|
+
return /^\d+$/.test(s) || GATE_NOT_APPLICABLE.has(s.toLowerCase());
|
|
282
411
|
case "db/name":
|
|
283
|
-
|
|
412
|
+
// M103: an environment that is not a database (a hosting account, a web
|
|
413
|
+
// console) has no db name. `n/a` is true, not hidden.
|
|
414
|
+
return gateIsDbNameShape(s) || GATE_NOT_APPLICABLE.has(s.toLowerCase());
|
|
284
415
|
case "auth method":
|
|
285
|
-
|
|
416
|
+
// M103: the enum can never be complete (every vendor names sign-in its
|
|
417
|
+
// own way — `cli-session`, `device-code`). An unenumerated method is
|
|
418
|
+
// accepted ONLY in LABEL shape: short lowercase hyphenated words. A
|
|
419
|
+
// credential is not that shape (see gateIsAuthLabelShape).
|
|
420
|
+
return GATE_AUTH_METHODS.has(s.toLowerCase()) || gateIsAuthLabelShape(s);
|
|
286
421
|
case "secret vault":
|
|
287
422
|
return GATE_VAULTS.has(s.toLowerCase());
|
|
288
423
|
case "secret env-var NAME":
|
|
289
|
-
|
|
424
|
+
// M103: an environment reached by an interactive CLI session carries no
|
|
425
|
+
// env var. `n/a` is the true answer; it is not a secret in any shape.
|
|
426
|
+
return GATE_UPPER_SNAKE.test(s) || GATE_NOT_APPLICABLE.has(s.toLowerCase());
|
|
290
427
|
case "fetch command":
|
|
291
428
|
case "connect command":
|
|
292
429
|
return gateCommandOk(s);
|
|
293
430
|
case "access gotchas":
|
|
294
431
|
return gateGotchasOk(s);
|
|
295
432
|
case "read-only default":
|
|
296
|
-
|
|
433
|
+
// M103: a boolean is a boolean whatever its capitalisation. Rejecting
|
|
434
|
+
// `yes` taught humans to retype until the checker relented.
|
|
435
|
+
return /^(yes|no)$/i.test(s);
|
|
297
436
|
case "recorded":
|
|
298
437
|
return GATE_ISO_TS.test(s);
|
|
438
|
+
case "source":
|
|
439
|
+
// M103 — where an otherwise-unprovable value came from. It must itself be
|
|
440
|
+
// checkable: a command built from the curated CLI words, or a path to a
|
|
441
|
+
// file in the project. Free prose here would re-open the hole the column
|
|
442
|
+
// exists to close.
|
|
443
|
+
return gateIsSourceShape(s);
|
|
299
444
|
default:
|
|
300
445
|
// Unknown column — fall back to refusing anything the backstop flags.
|
|
301
446
|
return !cellHitsBackstop(s);
|
|
@@ -305,10 +450,20 @@ function cellMatchesColumnShape(col, cell) {
|
|
|
305
450
|
// The gate's leak test for a cell in a KNOWN column: FAIL if it is NOT the
|
|
306
451
|
// column's positive shape OR (backstop) it hits the known-prefix/embedded-cred
|
|
307
452
|
// detector. The positive shape is the PRIMARY guard.
|
|
308
|
-
function cellLeaks(col, cell) {
|
|
453
|
+
function cellLeaks(col, cell, hasSource) {
|
|
309
454
|
if (typeof cell !== "string" || !cell) return false;
|
|
310
|
-
|
|
311
|
-
|
|
455
|
+
// BACKSTOP FIRST, and it is absolute: a value that positively looks like a
|
|
456
|
+
// credential (known prefix / JWT / base64 / hex / embedded cred) fails no
|
|
457
|
+
// matter what the row claims about where it came from. A source vouches for
|
|
458
|
+
// the UNRECOGNISED, never for the recognisably-secret.
|
|
459
|
+
if (cellHitsBackstop(cell)) return true;
|
|
460
|
+
if (!cellMatchesColumnShape(col, cell)) {
|
|
461
|
+
// M103 — the shape grammar cannot recognise this value. If the row names
|
|
462
|
+
// where it came from, that is the vouching the shape cannot provide.
|
|
463
|
+
// The `source` column itself is never vouched for by its own presence.
|
|
464
|
+
if (hasSource && col !== "source") return false;
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
312
467
|
return false;
|
|
313
468
|
}
|
|
314
469
|
|
|
@@ -354,6 +509,98 @@ function readAllowLocalLiteral(projectDir) {
|
|
|
354
509
|
}
|
|
355
510
|
}
|
|
356
511
|
|
|
512
|
+
// ─── M103 — does this project reach anything that is NOT on this machine? ────
|
|
513
|
+
//
|
|
514
|
+
// The M102 gate treated "no table and no rule" as "hasn't adopted the registry
|
|
515
|
+
// yet" and PASSED. That certified the exact emptiness the registry exists to
|
|
516
|
+
// prevent: 31 of 33 projects sat in that state, so every session re-asked the
|
|
517
|
+
// human for connection details.
|
|
518
|
+
//
|
|
519
|
+
// A project only NEEDS a map if it reaches a remote environment. So the gate
|
|
520
|
+
// now looks for evidence of one. The signals are re-derived HERE rather than
|
|
521
|
+
// imported from the writer's detectEnvConfig — the gate must stay independently
|
|
522
|
+
// implemented (a writer bug must never disable it), same invariant M102 set for
|
|
523
|
+
// the shape grammar.
|
|
524
|
+
//
|
|
525
|
+
// This is a HALT, not a fallback: a project with a remote environment and no
|
|
526
|
+
// map FAILS and says which marker it found. A genuinely local-only project
|
|
527
|
+
// PASSES and is NAMED as local-only, so "passed" never silently means
|
|
528
|
+
// "unchecked" (no-silent-degradation).
|
|
529
|
+
const REMOTE_MARKER_FILES = [
|
|
530
|
+
["vercel.json", "Vercel"],
|
|
531
|
+
[".vercel", "Vercel"],
|
|
532
|
+
["cloudbuild.yaml", "Google Cloud"],
|
|
533
|
+
["cloudbuild.yml", "Google Cloud"],
|
|
534
|
+
[".gcloudignore", "Google Cloud"],
|
|
535
|
+
["fly.toml", "Fly.io"],
|
|
536
|
+
["render.yaml", "Render"],
|
|
537
|
+
["railway.json", "Railway"],
|
|
538
|
+
["app.yaml", "Google App Engine"],
|
|
539
|
+
["netlify.toml", "Netlify"],
|
|
540
|
+
["wrangler.toml", "Cloudflare"],
|
|
541
|
+
["captain-definition", "CapRover"],
|
|
542
|
+
[".neon", "Neon"],
|
|
543
|
+
];
|
|
544
|
+
// A dependency whose presence means a hosted service is being talked to.
|
|
545
|
+
const REMOTE_DEP_HINTS = [
|
|
546
|
+
["@neondatabase/serverless", "Neon"],
|
|
547
|
+
["@vercel/postgres", "Vercel Postgres"],
|
|
548
|
+
["@supabase/supabase-js", "Supabase"],
|
|
549
|
+
["@planetscale/database", "PlanetScale"],
|
|
550
|
+
["@aws-sdk/client-s3", "AWS"],
|
|
551
|
+
["@google-cloud/storage", "Google Cloud"],
|
|
552
|
+
["mongodb", "MongoDB"],
|
|
553
|
+
];
|
|
554
|
+
|
|
555
|
+
// M103 — "a recorded command must run as written" was TRIED as a gate check
|
|
556
|
+
// here and REMOVED. Two reasons, both found by the existing tests:
|
|
557
|
+
// 1. It is not a secret question. Bolting a usefulness check onto the leak
|
|
558
|
+
// gate made it fail rows that are perfectly safe — three pre-existing
|
|
559
|
+
// tests assert bare `neonctl connection-string` is legitimate, correctly.
|
|
560
|
+
// 2. The rule was not universally true. `neonctl connection-string` resolves
|
|
561
|
+
// fine in a single-project account; it only opens a picker when the
|
|
562
|
+
// account holds several (as David's does). Generalising one environment's
|
|
563
|
+
// behaviour into a universal requirement is the guess this codebase's
|
|
564
|
+
// No-Fallback/evidence rules exist to stop.
|
|
565
|
+
// The requirement survives as guidance in the CLAUDE.md env-access rule, where
|
|
566
|
+
// the human recording the row can judge their own account shape.
|
|
567
|
+
|
|
568
|
+
function detectRemoteEnvironment(projectDir) {
|
|
569
|
+
const found = [];
|
|
570
|
+
for (const [file, label] of REMOTE_MARKER_FILES) {
|
|
571
|
+
try {
|
|
572
|
+
if (fs.existsSync(path.join(projectDir, file))) found.push(`${label} (${file})`);
|
|
573
|
+
} catch (_) { /* unreadable path is not evidence */ }
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
const pkgRaw = fs.readFileSync(path.join(projectDir, "package.json"), "utf8");
|
|
577
|
+
const pkg = JSON.parse(pkgRaw);
|
|
578
|
+
const deps = Object.assign({}, pkg.dependencies, pkg.devDependencies);
|
|
579
|
+
for (const [dep, label] of REMOTE_DEP_HINTS) {
|
|
580
|
+
if (Object.prototype.hasOwnProperty.call(deps, dep)) found.push(`${label} (${dep})`);
|
|
581
|
+
}
|
|
582
|
+
} catch (_) { /* no/unparseable package.json is not evidence */ }
|
|
583
|
+
return found;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Does the table hold at least one row describing a NON-local environment?
|
|
587
|
+
// A map listing only `scope=local` rows does not answer "how do I reach prod".
|
|
588
|
+
function hasNonLocalRow(infra) {
|
|
589
|
+
const start = infra.indexOf(ENV_MARKER_START);
|
|
590
|
+
const end = infra.indexOf(ENV_MARKER_END);
|
|
591
|
+
if (start === -1 || end === -1) return false;
|
|
592
|
+
const lines = infra.slice(start, end).split("\n");
|
|
593
|
+
for (const line of lines) {
|
|
594
|
+
if (!line.trim().startsWith("|")) continue;
|
|
595
|
+
const cells = splitRow(line);
|
|
596
|
+
if (cells[0] === "id") continue;
|
|
597
|
+
if (cells.every((c) => /^-{1,}$/.test(c) || c === "")) continue;
|
|
598
|
+
const scope = (cells[1] || "").trim().toLowerCase();
|
|
599
|
+
if (scope === "prod" || scope === "staging") return true;
|
|
600
|
+
}
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
|
|
357
604
|
function check(projectDir) {
|
|
358
605
|
const infraPath = path.join(projectDir, "docs", "infrastructure.md");
|
|
359
606
|
const claudePath = path.join(projectDir, "CLAUDE.md");
|
|
@@ -364,14 +611,42 @@ function check(projectDir) {
|
|
|
364
611
|
|
|
365
612
|
const hasMarkers = infra.includes(ENV_MARKER_START) && infra.includes(ENV_MARKER_END);
|
|
366
613
|
// The env-access rule is identified by its stable marker phrase.
|
|
367
|
-
|
|
614
|
+
//
|
|
615
|
+
// M103 — the rule ships in the GLOBAL ~/.claude/CLAUDE.md (via
|
|
616
|
+
// templates/CLAUDE-global.md), NOT in a project CLAUDE.md. M102 looked only
|
|
617
|
+
// at the project file, so hasRule was false in EVERY project, which sent
|
|
618
|
+
// every project down the "hasn't adopted it" no-op-PASS branch. Check both:
|
|
619
|
+
// the project file first (a project may restate it), then the global.
|
|
620
|
+
const RULE_PHRASE = /Environment Access — read-first, HALT-and-document/;
|
|
621
|
+
const globalClaude = readSafe(path.join(os.homedir(), ".claude", "CLAUDE.md")) || "";
|
|
622
|
+
const hasRule = RULE_PHRASE.test(claude) || RULE_PHRASE.test(globalClaude);
|
|
368
623
|
|
|
369
624
|
const failures = [];
|
|
370
625
|
|
|
371
|
-
// (b) rule
|
|
372
|
-
|
|
626
|
+
// (b) M102 asked "the rule promises a map — is there a table?". That made
|
|
627
|
+
// sense while the rule was believed to be per-project. It is not: the rule
|
|
628
|
+
// ships in the GLOBAL CLAUDE.md, so once M103 reads the global file the
|
|
629
|
+
// condition is true in EVERY project and fires on local-only projects that
|
|
630
|
+
// have nothing remote to map (measured: 21 of 28 projects failed on this
|
|
631
|
+
// alone). A gate that fails a project for a correct state gets switched off.
|
|
632
|
+
//
|
|
633
|
+
// Condition (c) below asks the question (b) was reaching for, and asks it
|
|
634
|
+
// against evidence from the project itself rather than a globally-true
|
|
635
|
+
// premise: does this project REACH something remote, and is it mapped?
|
|
636
|
+
// (b) is therefore removed, not merely relaxed — it added no signal (c)
|
|
637
|
+
// does not already carry.
|
|
638
|
+
|
|
639
|
+
// (c) M103 — a project that reaches a REMOTE environment must map it. An
|
|
640
|
+
// empty (or local-only) map in a project with a deploy marker is the state
|
|
641
|
+
// that made every session re-ask the human for connection details.
|
|
642
|
+
const remoteMarkers = detectRemoteEnvironment(projectDir);
|
|
643
|
+
const mapsARemoteEnv = hasMarkers && hasNonLocalRow(infra);
|
|
644
|
+
const isLocalOnly = remoteMarkers.length === 0;
|
|
645
|
+
if (!isLocalOnly && !mapsARemoteEnv) {
|
|
373
646
|
failures.push(
|
|
374
|
-
|
|
647
|
+
`project reaches a remote environment (${remoteMarkers.join(", ")}) but the ` +
|
|
648
|
+
"`## Environments` table has no prod/staging row — record it with " +
|
|
649
|
+
"`gsd-t env-registry record`, so the connection is not rediscovered every session"
|
|
375
650
|
);
|
|
376
651
|
}
|
|
377
652
|
|
|
@@ -391,7 +666,11 @@ function check(projectDir) {
|
|
|
391
666
|
// check below — a malformed schema always fails).
|
|
392
667
|
const rowScope = (cells[1] || "").trim();
|
|
393
668
|
const exemptSecrets = allowLocalLiteral && rowScope === "local";
|
|
394
|
-
//
|
|
669
|
+
// M103 — does this row say where its values came from? A named source
|
|
670
|
+
// vouches for a value the shape-grammar cannot recognise (a vendor
|
|
671
|
+
// resource id). It never vouches past the backstop.
|
|
672
|
+
const hasSource = rowNamesASource(cells);
|
|
673
|
+
// An OVERFLOW cell (index ≥ the fixed column schema) is itself a
|
|
395
674
|
// corruption signal — a hand-edit/merge/tool that appended a 15th column
|
|
396
675
|
// could hide a plaintext secret in a column the shape-map doesn't cover.
|
|
397
676
|
// The old `col${i}` default branch fell back to the WEAK backstop only
|
|
@@ -409,9 +688,11 @@ function check(projectDir) {
|
|
|
409
688
|
}
|
|
410
689
|
if (exemptSecrets) continue; // opted-in local row — skip secret-leak check
|
|
411
690
|
const col = ENV_COLUMNS[i];
|
|
412
|
-
if (cellLeaks(col, cells[i])) {
|
|
691
|
+
if (cellLeaks(col, cells[i], hasSource)) {
|
|
413
692
|
failures.push(
|
|
414
|
-
|
|
693
|
+
hasSource
|
|
694
|
+
? `Environments row cell (${col}) contains a secret-shaped literal value: "${cells[i]}" — naming a source does not permit a value that looks like a credential; record the env-var NAME and a $VAR reference`
|
|
695
|
+
: `Environments row cell (${col}) contains a secret-shaped literal value: "${cells[i]}" — record the env-var NAME and a $VAR reference, never a literal secret, OR name where the value came from in the \`source\` column if it is a vendor resource id`
|
|
415
696
|
);
|
|
416
697
|
}
|
|
417
698
|
}
|
|
@@ -423,10 +704,16 @@ function check(projectDir) {
|
|
|
423
704
|
check: "env-registry",
|
|
424
705
|
hasMarkers,
|
|
425
706
|
hasRule,
|
|
707
|
+
// M103 — the reason for a PASS is always NAMED. "local-only" is a real
|
|
708
|
+
// verdict about this project; it never silently means "not checked".
|
|
709
|
+
localOnly: isLocalOnly,
|
|
710
|
+
remoteMarkers,
|
|
426
711
|
failures,
|
|
712
|
+
// Only describe a PASS when it IS one — a note saying "PASS" beside
|
|
713
|
+
// ok:false is exactly the kind of mixed signal this gate exists to remove.
|
|
427
714
|
note:
|
|
428
|
-
|
|
429
|
-
? "
|
|
715
|
+
isLocalOnly && failures.length === 0
|
|
716
|
+
? "PASS: local-only — no deploy marker or hosted-service dependency found, so there is no remote environment to map"
|
|
430
717
|
: undefined,
|
|
431
718
|
};
|
|
432
719
|
}
|
|
@@ -50,6 +50,12 @@ const ENV_COLUMNS = [
|
|
|
50
50
|
"access gotchas",
|
|
51
51
|
"read-only default",
|
|
52
52
|
"recorded",
|
|
53
|
+
// M103 — WHERE a value came from. Filled ONLY when a cell holds something the
|
|
54
|
+
// checker cannot prove safe by its shape (a vendor's project id looks exactly
|
|
55
|
+
// like a token). Naming the source is what makes such a value acceptable; its
|
|
56
|
+
// PRESENCE is the flag, so there is no per-vendor list to keep up to date.
|
|
57
|
+
// Empty for rows whose every cell stands on its own — most rows.
|
|
58
|
+
"source",
|
|
53
59
|
];
|
|
54
60
|
|
|
55
61
|
// ─── Secret-value BACKSTOP detector (known-prefix / JWT / embedded-cred) ─────
|
|
@@ -252,10 +252,56 @@ function loadGrandfathered(dir) {
|
|
|
252
252
|
const t = l.trim();
|
|
253
253
|
if (t && !t.startsWith("#")) out.add(t);
|
|
254
254
|
}
|
|
255
|
-
} catch { /* absent list = nothing grandfathered */ }
|
|
255
|
+
} catch { /* absent list = nothing grandfathered (see seedGrandfatherList) */ }
|
|
256
256
|
return out;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/**
|
|
260
|
+
* First-run seed for a project that already had PseudoCode docs before this gate
|
|
261
|
+
* existed.
|
|
262
|
+
*
|
|
263
|
+
* WHY THIS IS NOT A FALLBACK (No-Fallback-Ever doctrine):
|
|
264
|
+
* this does not continue past a failure or mask one. It establishes the gate's
|
|
265
|
+
* STARTING LINE. The gate governs docs authored under contract §1.1; docs that
|
|
266
|
+
* predate it were written to a different (then-valid) standard, and retroactively
|
|
267
|
+
* failing them would make an unrelated verify run fail in a downstream project for
|
|
268
|
+
* work nobody touched. That is a false failure, not a caught defect.
|
|
269
|
+
*
|
|
270
|
+
* The seed is: WRITTEN ONCE (never rewritten — a list that re-seeded itself would
|
|
271
|
+
* silently absolve every newly-drifted doc, which IS the banned behavior), keyed on
|
|
272
|
+
* the list file being ABSENT, and always SURFACED with reason "seeded-pre-existing"
|
|
273
|
+
* naming every doc it covers. A doc created AFTER the seed is gated normally.
|
|
274
|
+
*
|
|
275
|
+
* @returns {{ seeded: boolean, names: string[], error?: string }}
|
|
276
|
+
*/
|
|
277
|
+
function seedGrandfatherList(dir, docBasenames) {
|
|
278
|
+
const listPath = path.join(dir, GRANDFATHER_FILE);
|
|
279
|
+
if (fs.existsSync(listPath)) return { seeded: false, names: [] };
|
|
280
|
+
if (docBasenames.length === 0) return { seeded: false, names: [] };
|
|
281
|
+
|
|
282
|
+
const body = [
|
|
283
|
+
"# Docs that predate the §1.1 flow-line style gate (contract v1.2.0).",
|
|
284
|
+
"# Seeded automatically on this gate's FIRST run in this project, so pre-existing",
|
|
285
|
+
"# docs are not retroactively failed. Each is reported as a logged skip WITH A",
|
|
286
|
+
"# REASON — never a silent pass.",
|
|
287
|
+
"#",
|
|
288
|
+
"# This file is written ONCE and never re-seeded: a doc created after this point",
|
|
289
|
+
"# is gated normally. Removing a name here is how a doc opts INTO the gate —",
|
|
290
|
+
"# convert it to the §1.1 flow style in the same change.",
|
|
291
|
+
...docBasenames,
|
|
292
|
+
"",
|
|
293
|
+
].join("\n");
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
fs.writeFileSync(listPath, body, "utf8");
|
|
297
|
+
} catch (e) {
|
|
298
|
+
// Cannot write (read-only checkout, permissions) — surface it, do NOT
|
|
299
|
+
// silently fail 20 docs and do NOT silently pass them either.
|
|
300
|
+
return { seeded: false, names: [], error: `cannot seed ${GRANDFATHER_FILE}: ${e && e.message}` };
|
|
301
|
+
}
|
|
302
|
+
return { seeded: true, names: docBasenames.slice() };
|
|
303
|
+
}
|
|
304
|
+
|
|
259
305
|
/**
|
|
260
306
|
* Run the gate over one doc or a whole directory (§7 discovery glob).
|
|
261
307
|
* Never throws.
|
|
@@ -283,6 +329,17 @@ function run({ doc, dir }) {
|
|
|
283
329
|
}
|
|
284
330
|
}
|
|
285
331
|
|
|
332
|
+
// First-run seed: a project whose docs predate this gate gets its starting line
|
|
333
|
+
// established once, surfaced with a reason. Only on the --dir path (the whole-set
|
|
334
|
+
// view); a single --doc call never seeds.
|
|
335
|
+
let seedInfo = null;
|
|
336
|
+
if (dir) {
|
|
337
|
+
seedInfo = seedGrandfatherList(dir, docs.map((d) => path.basename(d)).sort());
|
|
338
|
+
if (seedInfo.error) {
|
|
339
|
+
return { ok: false, exitCode: 64, reason: seedInfo.error, violations: [] };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
286
343
|
const results = [];
|
|
287
344
|
const violations = [];
|
|
288
345
|
const skips = [];
|
|
@@ -295,7 +352,7 @@ function run({ doc, dir }) {
|
|
|
295
352
|
if (r.exitCode > worstExit) worstExit = r.exitCode;
|
|
296
353
|
}
|
|
297
354
|
|
|
298
|
-
|
|
355
|
+
const envelope = {
|
|
299
356
|
ok: worstExit === 0,
|
|
300
357
|
exitCode: worstExit,
|
|
301
358
|
docsChecked: docs.length,
|
|
@@ -303,6 +360,11 @@ function run({ doc, dir }) {
|
|
|
303
360
|
skips,
|
|
304
361
|
violations,
|
|
305
362
|
};
|
|
363
|
+
if (seedInfo && seedInfo.seeded) {
|
|
364
|
+
// Surfaced, never silent: name the migration and every doc it covers.
|
|
365
|
+
envelope.seeded = { reason: "seeded-pre-existing", count: seedInfo.names.length, docs: seedInfo.names };
|
|
366
|
+
}
|
|
367
|
+
return envelope;
|
|
306
368
|
}
|
|
307
369
|
|
|
308
370
|
function parseArgs(argv) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.6.10",
|
|
4
4
|
"description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -106,6 +106,8 @@ NEED TO UNDERSTAND SOMETHING?
|
|
|
106
106
|
|
|
107
107
|
- **On a HIT** — use the recorded connect command; the secret is pulled from its vault at runtime via the env-var NAME.
|
|
108
108
|
- **On a MISS (no row) — HALT and document. NEVER guess a connection string, NEVER grep transcripts to rediscover it** (No-Fallback-Ever). Then: `detect` the env → ask the human to confirm/fill → `record` the map row → `add-permission` (broad-glob) → proceed. The registry self-heals staleness because a re-provision upserts by `(scope, kind)`.
|
|
109
|
+
- **WRITE THE ROW BEFORE YOU USE THE ANSWER (M103).** The moment the human answers is the ONLY moment both a human and a real answer are in the same place. `record` FIRST, then continue the work that needed it. Using the answer and recording it "after this task" is how the map stayed empty in 31 of 33 projects and why the same question got asked again next session. This is not a fallback: the HALT already fired and was answered — writing it down is what COMPLETES the halt.
|
|
110
|
+
- **A recorded command must RUN AS WRITTEN (M103).** Record the command you actually verified, including every identifier it needs (project id, branch, org). `neonctl connection-string` with no `--project-id` drops into an interactive picker and answers nothing unattended; `neonctl connection-string --project-id winter-frog-54927244 --database-name neondb` works. A command that needs a fact it does not carry is an incomplete row — it looks recorded and still forces the next session to ask.
|
|
109
111
|
- **Record-at-create (greenfield):** whenever GSD-T BUILDS/PROVISIONS an environment, record its map row in the SAME pass (it has the URL + creds right then).
|
|
110
112
|
- **Never write a secret VALUE into the registry** — only the vault name + env-var NAME + a `$VAR`-referencing command. A literal secret is rejected by `recordEnvironment` and by the `gsd-t-verify` env-registry gate. If a secret is found in a URL/command, the capture flow OFFERS to move it into the vault (rotate-or-move, user's choice) and replace it with a `$VAR`.
|
|
111
113
|
- **Local-literal switch:** a project may opt in via `.gsd-t/env-registry-config.json` `{"allowLocalLiteral": true}` to allow a literal secret in `scope=local` rows only; `staging`/`prod` stay strict.
|
|
@@ -79,8 +79,8 @@ cp .env.example .env
|
|
|
79
79
|
> document (detect → ask → record → proceed); never guess a connection string,
|
|
80
80
|
> never grep transcripts to rediscover.
|
|
81
81
|
|
|
82
|
-
| id | scope | kind | host | port | db/name | auth method | secret vault | secret env-var NAME | fetch command | connect command | access gotchas | read-only default | recorded |
|
|
83
|
-
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
82
|
+
| id | scope | kind | host | port | db/name | auth method | secret vault | secret env-var NAME | fetch command | connect command | access gotchas | read-only default | recorded | source |
|
|
83
|
+
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
84
84
|
<!-- gsd-t-env-registry:end -->
|
|
85
85
|
|
|
86
86
|
<!--
|
|
@@ -92,17 +92,35 @@ cp .env.example .env
|
|
|
92
92
|
host = POSITIVE shape: localhost | IPv4 | dotted DNS hostname |
|
|
93
93
|
short lowercase service name (letters+hyphen, no digits, e.g. db, postgres)
|
|
94
94
|
db/name = short lowercase snake identifier (<=16, e.g. binvoice_prod, analytics)
|
|
95
|
-
auth method =
|
|
96
|
-
|
|
95
|
+
auth method = password | iam | oauth | oauth2 | service-account | ssh-key |
|
|
96
|
+
api-key | none | scram-sha-256 | md5 | trust | token | key | ...
|
|
97
|
+
A method the list has not heard of (cli-session, device-code) is
|
|
98
|
+
accepted in LABEL shape: short lowercase hyphenated words, no digits.
|
|
97
99
|
fetch command = how to pull the secret from its vault (e.g. `vercel env pull`)
|
|
98
100
|
POSITIVE allowlist: a token is accepted ONLY if it IS a $VAR/${VAR} ref,
|
|
99
101
|
a flag, a hostname/IP, a .env dotfile, or a curated CLI/db word.
|
|
100
102
|
Any OTHER bare literal is REJECTED (move it to an env var, reference as $VAR).
|
|
101
103
|
connect command = references the env-var by name: `psql "$DATABASE_URL_PROD"` (same allowlist)
|
|
102
|
-
access gotchas =
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
access gotchas = vpn | ip-allowlist | ssh-tunnel | bastion | none, optionally
|
|
105
|
+
`via <hostname>` (e.g. `ssh-tunnel via bastion.example.com`).
|
|
106
|
+
PLAIN-ENGLISH judgment is also allowed here and is the most
|
|
107
|
+
valuable cell in the row ("live customer data — never mutate
|
|
108
|
+
without an explicit OK"): no vendor CLI can ever return it.
|
|
109
|
+
Checked word-by-word — ordinary words and numbers pass; a
|
|
110
|
+
credential-shaped token does not.
|
|
111
|
+
read-only default = yes | no (any capitalisation). YES for scope=prod unless a
|
|
112
|
+
human explicitly recorded write-ok.
|
|
113
|
+
port / db/name / secret env-var NAME
|
|
114
|
+
= `n/a` is a valid TRUE answer where the column does not apply
|
|
115
|
+
(a web console has no port; a hosting account is not a database).
|
|
116
|
+
source = WHERE an unprovable value came from — a command
|
|
117
|
+
(`neonctl projects list`) or a project file (`.vercel/project.json`).
|
|
118
|
+
Fill it ONLY when a cell holds something the checker cannot
|
|
119
|
+
recognise by shape: a vendor resource id (winter-frog-54927244)
|
|
120
|
+
is indistinguishable from a token, so naming its source is what
|
|
121
|
+
makes it acceptable. Its PRESENCE is the flag — there is no
|
|
122
|
+
per-vendor list to maintain. It never excuses a value that
|
|
123
|
+
positively looks like a credential.
|
|
106
124
|
There is deliberately NO secret-value column — a row is structurally incapable
|
|
107
125
|
of holding a secret. Populated by `gsd-t-env-registry` (record-at-create +
|
|
108
126
|
capture-on-first-need).
|