From 9893a70d1e9d59f4ae4e2ffd796b20c29efed299 Mon Sep 17 00:00:00 2001 From: BradleyDB <23158057+BradleyDB@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:53:06 -0700 Subject: [PATCH 1/4] =?UTF-8?q?Session=20C2:=20one=20home=20per=20kind=20o?= =?UTF-8?q?f=20fact=20(F-450)=20and=20the=20scope=20relay=20(F-452)=20?= =?UTF-8?q?=E2=80=94=20plugin=200.41.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FH-1: per-CLI facts ship in doc-lib CLI_PIN_FACTS (keyed by catalog id, version-stamped, self-retiring; check-stale-facts pins the stamp, test/domain-candidates holds every id to the bundled catalog). domain-candidates diff buckets the four re rules sublists as notEnumerableBare with inert tenant records; manifest report derives domains..scope and pinFacts (T-2 v4, additive, conformance-pinned) from each stamp's recorded listCommand — nothing stamped per tenant, no backfill. index-scope-notes restructured into one subsection per limited command; check-doc-drift check 23 holds table and canon both ways. CONVENTIONS.md is workspace-wide by default with a /CONVENTIONS.md override (readAliasConvention reads the tenant file first and names its home; precedence tenant file > adopted pack > workspace file at every prose site). A blank date string is not a date: one isBlankDate/isDateless predicate across upsert, newerThan, baseline and report (the connectors-chains disagreement's cause), all-blank lists warn with the redate remedy. RP-4: the setup Phase 4 relay names each scope-limited domain from scope with its limit before the totals question; setup/refresh/upsert-batch stop listing the commands by hand. Review round (medium): 8 confirmed findings fixed. Bus: F-450, F-452 FIXED; live arms in dev/VALIDATION.md. --- build/check-doc-drift.mjs | 46 ++++++++ build/check-stale-facts.mjs | 6 + build/test-check-doc-drift.mjs | 47 ++++++++ build/test-check-stale-facts.mjs | 32 ++++- dev/FEEDBACK.md | 9 +- dev/VALIDATION.md | 43 +++++++ .../gs-superadmin/.claude-plugin/plugin.json | 2 +- plugins/gs-superadmin/CHANGELOG.md | 55 +++++++++ plugins/gs-superadmin/MAINTAINERS.md | 6 +- plugins/gs-superadmin/README.md | 4 +- plugins/gs-superadmin/scripts/doc-lib.mjs | 89 ++++++++++++++ .../scripts/domain-candidates.mjs | 64 ++++++++++ .../gs-superadmin/scripts/jo-report-deps.mjs | 80 +++++++++++-- plugins/gs-superadmin/scripts/manifest.mjs | 111 ++++++++++++++++-- plugins/gs-superadmin/scripts/tenant-deps.mjs | 5 +- plugins/gs-superadmin/skills/audit/SKILL.md | 11 +- .../skills/change-request/SKILL.md | 6 +- .../gs-superadmin/skills/deprecate/SKILL.md | 8 +- .../gs-superadmin/skills/deps-report/SKILL.md | 6 +- .../skills/email-report/SKILL.md | 6 +- plugins/gs-superadmin/skills/refresh/SKILL.md | 10 +- plugins/gs-superadmin/skills/setup/SKILL.md | 63 +++++++--- .../setup/references/index-scope-notes.md | 67 ++++++++--- .../gs-superadmin/templates/CONVENTIONS.md | 7 +- .../templates/operating-model.md | 10 +- .../test/contract-conformance.mjs | 19 ++- .../gs-superadmin/test/domain-candidates.mjs | 108 ++++++++++++++++- .../jo-report/deps/expected-deps-caveats.csv | 2 +- .../jo-report/deps/expected-report.md | 2 +- plugins/gs-superadmin/test/jo-report-deps.mjs | 47 +++++++- plugins/gs-superadmin/test/manifest-ops.mjs | 53 ++++++++- plugins/gs-superadmin/test/tenant-deps.mjs | 2 +- 32 files changed, 927 insertions(+), 99 deletions(-) diff --git a/build/check-doc-drift.mjs b/build/check-doc-drift.mjs index a263a22..e627501 100644 --- a/build/check-doc-drift.mjs +++ b/build/check-doc-drift.mjs @@ -2236,6 +2236,51 @@ for (const rel of trackedMd.filter((p) => RUNNABLE_DOC_PREFIXES.some((pre) => p. }); } +// ── check 23 (F-450 c): the scope-limit canon and the per-pin table agree both ways ── +// doc-lib's CLI_PIN_FACTS carries a `scope` paraphrase per scope-limited list +// command (keyed by catalog id, ONE home, read by report); the canon is prose — +// one `###` subsection per such command under "Scope-limited domains" in +// setup's index-scope-notes.md, headed by the command's canonical path (the +// id with its colons as spaces), which report's `scope.path` points at. A +// table entry with no subsection is a pointer into nothing; a subsection with +// no entry is a limit the report never names. Both are read out of source +// text (check 22's precedent — the build lane may not import the plugin), and +// a reader that no longer matches fails loudly rather than sweeping nothing. +const SCOPE_NOTES_REL = "plugins/gs-superadmin/skills/setup/references/index-scope-notes.md"; +const pinBlock = /export const CLI_PIN_FACTS = Object\.freeze\(\{[\s\S]*?\n\}\);/.exec(read(DOC_LIB_REL)); +const scopeEntryRe = /"([a-z0-9-]+(?::[a-z0-9-]+)+)": Object\.freeze\(\{[^}]*?\bscope: "/g; +const tableScopePaths = pinBlock ? [...pinBlock[0].matchAll(scopeEntryRe)].map((m) => m[1].split(":").join(" ")) : []; +if (!pinBlock || tableScopePaths.length < 1) { + fail(`check 23: could not read the scope entries out of ${DOC_LIB_REL}'s CLI_PIN_FACTS (got ${tableScopePaths.length}) — the table moved or its shape changed; fix the reader, never the sweep`); +} +// The notes are read through the file's one fence grammar (fenceRanges, as +// check 22 does): a fenced example carrying a heading-shaped line is neither +// canon nor a section terminator. The section ends at the next level-1 OR +// level-2 heading (review round: a later `# Appendix` with `###` subsections +// was being read as canon). +const scopeNotesLines = read(SCOPE_NOTES_REL).split(/\r?\n/); +const scopeFenced = fenceRanges(SCOPE_NOTES_REL, scopeNotesLines, "an unreadable fence hides its headings from check 23"); +const scopeInFence = (i) => scopeFenced.some(([a, b]) => i > a && i < b); +const scopeSectionStart = scopeNotesLines.findIndex((l, i) => !scopeInFence(i) && /^## Scope-limited domains\b/.test(l)); +let scopeSectionEnd = scopeNotesLines.length; +if (scopeSectionStart >= 0) for (let i = scopeSectionStart + 1; i < scopeNotesLines.length; i++) if (!scopeInFence(i) && /^#{1,2} /.test(scopeNotesLines[i])) { scopeSectionEnd = i; break; } +const notesScopePaths = scopeSectionStart < 0 ? [] : scopeNotesLines.slice(scopeSectionStart + 1, scopeSectionEnd).filter((l, k) => !scopeInFence(scopeSectionStart + 1 + k) && /^### /.test(l)).map((l) => l.slice(4).trim()); +if (scopeSectionStart < 0) fail(`check 23: ${SCOPE_NOTES_REL} has no "## Scope-limited domains" section — the canon report's scope.path points at is gone (F-450 c)`); +// Both comparison loops run only when BOTH readers succeeded: fail() counts +// rather than throws, and a reader failure followed by the sweep printed one +// spurious "drop the subsection" per canon subsection — the opposite of "fix +// the reader, never the sweep" (review round). +if (tableScopePaths.length && scopeSectionStart >= 0) { + for (const p of tableScopePaths) { + if (!notesScopePaths.includes(p)) + fail(`check 23: doc-lib's CLI_PIN_FACTS carries a scope entry for "${p}" but ${SCOPE_NOTES_REL} has no "### ${p}" subsection under "Scope-limited domains" — report's scope.path would point at nothing; add the canon subsection (F-450 c)`); + } + for (const p of notesScopePaths) { + if (!tableScopePaths.includes(p)) + fail(`check 23: ${SCOPE_NOTES_REL} carries a "### ${p}" subsection under "Scope-limited domains" but doc-lib's CLI_PIN_FACTS has no scope entry for its command — report would never name that limit; add the table entry or drop the subsection (F-450 c)`); + } +} + // The fail() gate sits HERE, after the last check, and must stay last (F-258). // It used to sit immediately after check 12 — with four checks and the pass line // below it — so any fail() raised further down printed its message and then let the @@ -2258,6 +2303,7 @@ const passLine = `${sweep9Read} of ${sweep9.length} files swept for portability-duplicate definitions, ` + `${lcCalls} localeCompare call sites locale-pinned, ` + `check 22: ${runnableLinesSwept} runnable line(s) in ${runnableDocsSwept} skill/template doc(s) name none of the ${laneFolders.length} default domain folders literally, ` + + `check 23: ${tableScopePaths.length} scope-limit entries in CLI_PIN_FACTS match the ${notesScopePaths.length} canon subsections both ways, ` + `${scriptRowsNamed} of ${scriptFiles.length} shipped scripts named in the MAINTAINERS Scripts table (rest are shared libs), ` + `${placeholderFences} placeholder fence(s) in ${pluginRootSkillsSeen.size} skill(s) (setup's pinned link fence; every other skill fence addresses scripts through ${WORKSPACE_LINK}), ` + `${check13Fences} hand-written doc fence(s) free of bash line continuations and ` + diff --git a/build/check-stale-facts.mjs b/build/check-stale-facts.mjs index 92b9e08..eef3d2b 100644 --- a/build/check-stale-facts.mjs +++ b/build/check-stale-facts.mjs @@ -525,6 +525,12 @@ const FACT_CARRIERS = [ // installed package's auth module; the version stamp beside it forces // a re-read of that file when the pin moves. { re: /\/\/ v(\d+\.\d+\.\d+) package's dist\/core\/auth\/index\.js/, expect: meta.cliVersion, what: "auth-death literal audit version" }, + // The per-pin CLI facts table (F-450): what the installed CLI does that + // its catalog does not declare — not-enumerable-bare sublists and scope + // limits — applied only while the catalog carries this exact version. + // The stamp is the table's self-retirement, and this pin forces the hand + // re-verification of every entry at each adoption. + { re: /export const CLI_PIN_FACTS = Object\.freeze\(\{\s*cliVersion: "(\d+\.\d+\.\d+)"/, expect: meta.cliVersion, what: "per-pin CLI facts table version stamp (CLI_PIN_FACTS)" }, ], }, ]; diff --git a/build/test-check-doc-drift.mjs b/build/test-check-doc-drift.mjs index b7b0f7b..4a7ec30 100644 --- a/build/test-check-doc-drift.mjs +++ b/build/test-check-doc-drift.mjs @@ -607,6 +607,53 @@ try { } finally { snap.restore(); } } + // ── check 23 · the scope-limit canon and the per-pin table agree both ways (F-450 c) ─ + { + const NOTES = "plugins/gs-superadmin/skills/setup/references/index-scope-notes.md"; + const DOCLIB = "plugins/gs-superadmin/scripts/doc-lib.mjs"; + const snap = snapshotFiles([at(NOTES), at(DOCLIB)]); + try { + mutate(NOTES, (s) => s.replace("### journey surveys list", "### journey surveys listing")); + const res = run(); + check("check 23a: a table entry whose canon subsection is missing (heading respelled) goes red naming the command and the file", + res.status === 1 && /check 23: .*scope entry for "journey surveys list" but .*index-scope-notes\.md has no "### journey surveys list" subsection/.test(res.stderr) && /"### journey surveys listing" subsection .* has no scope entry/.test(res.stderr), res.stderr); + } finally { snap.restore(); } + try { + mutate(NOTES, (s) => s.replace("## Recovering list-invisible email templates", "### connectors chains\n\nA limit nobody tabled.\n\n## Recovering list-invisible email templates")); + const res = run(); + check("check 23b: a canon subsection with no table entry goes red naming the subsection", + res.status === 1 && /check 23: .*"### connectors chains" subsection under "Scope-limited domains" but doc-lib's CLI_PIN_FACTS has no scope entry/.test(res.stderr), res.stderr); + } finally { snap.restore(); } + try { + mutate(NOTES, (s) => s.replace("## Recovering list-invisible email templates", "## Recovering\n\n### journey surveys list\n\n## Recovering list-invisible email templates").replace("### journey surveys list\n\nHardcodes", "### journey surveys list (moved)\n\nHardcodes")); + const res = run(); + check("check 23c: a subsection outside the Scope-limited section does not count as the canon", + res.status === 1 && /has no "### journey surveys list" subsection/.test(res.stderr), res.stderr); + } finally { snap.restore(); } + try { + mutate(DOCLIB, (s) => s.replace("export const CLI_PIN_FACTS = Object.freeze({", "export const CLI_PIN_FACTS = Object.freeze(Object.assign({}, {")); + const res = run(); + check("check 23d: the table's opener changing shape goes red at the reader, not silently as an empty sweep — and ONLY at the reader (no spurious per-subsection failures)", + res.status === 1 && /check 23: could not read the scope entries out of/.test(res.stderr) && (res.stderr.match(/check 23:/g) ?? []).length === 1, res.stderr); + } finally { snap.restore(); } + try { + mutate(NOTES, (s) => s.replace("### journey surveys list", "```markdown\n### journey fenceprobe list\n```\n\n### journey surveys list")); + const res = run(); + check("check 23f: a heading-shaped line inside a fenced example is neither canon nor a terminator (the file's fence grammar)", res.status === 0, res.stderr); + } finally { snap.restore(); } + try { + mutate(NOTES, (s) => s.replace("## Recovering list-invisible email templates", "# Appendix\n\n### journey appendix list\n\n## Recovering list-invisible email templates")); + const res = run(); + check("check 23g: a level-1 heading ends the section — a ### under a later # Appendix is not canon", res.status === 0, res.stderr); + } finally { snap.restore(); } + try { + mutate(NOTES, (s) => s.replace("## Scope-limited domains — paging cannot fix these", "## Scope limits — paging cannot fix these")); + const res = run(); + check("check 23e: the section heading itself renamed goes red naming the file (the anchor is read, F-439 class)", + res.status === 1 && /check 23: .*index-scope-notes\.md has no "## Scope-limited domains" section/.test(res.stderr), res.stderr); + } finally { snap.restore(); } + } + // ── check 11: guard-residuals anchor drift ───────────────────────────────── { const snap = snapshotFiles([at(PLUGIN_README)]); diff --git a/build/test-check-stale-facts.mjs b/build/test-check-stale-facts.mjs index 3c3389d..a3e5304 100644 --- a/build/test-check-stale-facts.mjs +++ b/build/test-check-stale-facts.mjs @@ -84,8 +84,7 @@ function runRig(files) { // …plus the auth-death literal's audit stamp (F-458), same home. writeFileSync( join(rig, "plugins", "gs-superadmin", "scripts", "doc-lib.mjs"), - `// strict writers went 29 → 0; POST reads 59 → 0\n// read actions in the v${PIN} catalog audited\n` + - `// the sentence every auth-path throw in the\n// v${PIN} package's dist/core/auth/index.js ends with\n`, + docLibStub(PIN), ); // Coverage floor (F-101): the checker requires ≥40 tracked .md files with // some nested — filler carries no version/count/flag tokens. @@ -107,6 +106,35 @@ function runRig(files) { } } +// The doc-lib carrier stub, parameterized on the version its CLI_PIN_FACTS +// stamp cites — the F-450 case below re-writes it with a stale stamp only. +function docLibStub(pinFactsVersion, pin = PIN) { + return ( + `// strict writers went 29 → 0; POST reads 59 → 0\n// read actions in the v${pin} catalog audited\n` + + `// the sentence every auth-path throw in the\n// v${pin} package's dist/core/auth/index.js ends with\n` + + `export const CLI_PIN_FACTS = Object.freeze({\n cliVersion: "${pinFactsVersion}",\n commands: Object.freeze({}),\n});\n` + ); +} + +// ── 0. F-450: the per-pin CLI facts table's version stamp is a carrier ────── +// A table stamped for another pin is not applied by its readers (self- +// retiring), so the stamp must trip here at adoption, by name, while the +// other doc-lib carriers stay green. +{ + // The stamp is a bare version (the const's own literal), so the stale form + // is STALE without its v. + const res = runRig({ "plugins/gs-superadmin/scripts/doc-lib.mjs": docLibStub(STALE.replace(/^v/, "")) }); + ok("stale CLI_PIN_FACTS stamp fails", res.status === 1); + ok("stale CLI_PIN_FACTS failure names the table and the pin", res.stderr.includes("CLI_PIN_FACTS") && res.stderr.includes(PIN)); + ok("the other doc-lib carriers stay green under a stale pin-facts stamp", !res.stderr.includes("read-verb allowlist") && !res.stderr.includes("auth-death")); + // A table that moved or lost its stamp is an unmatched pattern — itself a + // failure (silently unmatched prose is how the F-280 carriers went stale). + const gone = runRig({ "plugins/gs-superadmin/scripts/doc-lib.mjs": docLibStub(PIN).replace("export const CLI_PIN_FACTS", "const PIN_FACTS_MOVED") }); + ok("a missing CLI_PIN_FACTS stamp fails as an unmatched carrier pattern", gone.status === 1 && gone.stderr.includes("CLI_PIN_FACTS") && gone.stderr.includes("no longer pinned")); + const fresh = runRig({ "plugins/gs-superadmin/scripts/doc-lib.mjs": docLibStub(PIN) }); + ok("current CLI_PIN_FACTS stamp passes", fresh.status === 0); +} + // ── 1. Stale citation in bus PROSE: still fails ────────────────────────────── { const res = runRig({ "dev/FEEDBACK.md": `The pin is ${STALE} and that is a claim.\n` }); diff --git a/dev/FEEDBACK.md b/dev/FEEDBACK.md index 940295b..c8cce06 100644 --- a/dev/FEEDBACK.md +++ b/dev/FEEDBACK.md @@ -437,7 +437,7 @@ Sibling sweep: the ADOPT direction is the sibling the tester did not list — `u Ruled: 2026-09-11 (Bradley; measured by the builder on the sandbox KB, same asset under both domains) — the journey rows are Data Designer output datasets, part of data management and listed under the journey namespace only because a program can use one as a participant source; the `jo data-designer get` payload (objectName, label, fieldCount, fields) is a strict subset of `dm objects describe` (which adds dbName, group, dataStore, schema, stats), and a dataset's lineage lives in its design (`data-designer` domain), never here. Prod's coverage exclusion was correct; the sandbox adoption is a duplicate view. Its re-decision through the verb's ACCEPT path is banked as the second dev/VALIDATION.md section and rides the same tester round, so the judge below gets both directions on real data. Carried to Session C2 as prose: index-scope-notes says which dm objects are Data Designer outputs is "not determinable from CLI data" — `jo data-designer list` IS that determination for the status it filters on. Judge: (1) the fixture harness replays the live numbers through the real verb — 253 of 586 with --covered-by REFUSED quoting the numbers, 59 of 59 accepted ONLY as coverage, 3 + 3 split across two domains accepted only as judgment, partial and 0-row checks handled as the Expected demands (manifest-ops.mjs, domain-candidates.mjs, contract-conformance.mjs) — that is the fixer's own list; (2) independent of it, the tester's live arm on the sandbox, banked in dev/VALIDATION.md: lift the 2026-08-09 exclusion, capture `report list-objects` fresh, `check --out`, and the verb must refuse `--covered-by data-management` on the fresh count with nothing from the fixer in the loop, then adopt; afterwards the manifest carries an entry the diff reads as `judgment` or `coverage`, never `legacy`. A fresh count that differs from 253 of 586 is expected (the sandbox is in use) — the ratio is the claim, not the number. -## F-450 — OPEN +## F-450 — FIXED Reported: 2026-09-10 (tester — first live multi-tenant setup) Severity: normal — all three were invisible on a single-tenant workspace and went live the moment a second tenant existed What: three facts are stored at a scope that does not match what they describe. One class, three instances. @@ -445,6 +445,9 @@ What: three facts are stored at a scope that does not match what they describe. (b) Hard-required-flag exclusions (`--topic is required`, `ruleId or ruleName is required`, `--object-name … is required`) and the two server-side blocks are properties of the pinned CLI, not of a tenant, yet they live in `/_manifest.json`. The second tenant re-derived every one by live-failing three attempts per blocked domain, and the two tenants now disagree about what is physically enumerable. (c) Scope limits are absent from the manifest entirely: `domains_indexed["journey-email-templates"]` carries the same six fields as a fully-enumerable domain, so any consumer reading the manifest sees the count as complete. The limitation exists only as prose in `skills/setup/references/index-scope-notes.md`. Expected: one decision about where each kind of fact lives — per-tenant, per-workspace, or per-CLI-version — with readers deriving from that home rather than three independent placements. +Fix: 2026-09-15 (builder, Session C2 / FH-1; rulings, Bradley 2026-09-15 — (a) the workspace file is the default for every tenant and a `/CONVENTIONS.md` beside a tenant's KB overrides it for that tenant alone, the exception not the norm; (b) a plugin-shipped, version-stamped per-pin list; (c) the scope limit is DERIVED by `report` from that same table, never stamped per tenant) — one decision per KIND of fact, three homes. Class (not a registry key): a fact stored at the scope of the thing that happened to observe it — a per-CLI fact per tenant, a per-tenant fact per workspace — so a second tenant re-derives or inherits what is not its own. (a) `readAliasConvention` (jo-report-deps; tenant-deps and email-report read through it) resolves the tenant directory — the KB path's first segment under the workspace — and reads `/CONVENTIONS.md` when it EXISTS, the home whatever it contains (an unset or malformed declaration is reported against it, never completed from the workspace file: two files never read as one), else the workspace default; every result carries `home` and `path`, and the deps report header names the home ("the workspace CONVENTIONS.md, shared by every tenant" / "the tenant's own CONVENTIONS.md override"). The operating model's Build standards section is the canon; the template banner (which had promised "Tenant-specific" for a file every tenant read) and the audit, change-request, deprecate, deps-report and email-report prose paraphrase-plus-point at it. The scaffold writer still owns the workspace file; nobody auto-creates the tenant one. (b) doc-lib `CLI_PIN_FACTS`, keyed by catalog command `id` — NOT actionKey as the plan recommended: `list-source-fields` is the actionKey of two commands in two namespaces (measured) — stamped for the pin and applied only while the workspace catalog's cliVersion equals it (the ask-overrides precedent, self-retiring; under another pin the diff and the report say so and every sublist is ordinary work again). `domain-candidates.mjs diff` puts the four `re rules` sublists in a `notEnumerableBare` bucket BEFORE the record chain, each with the flag it needs and the runtime error; never undecided, never gating; a tenant exclusion or block recorded against one is `tenantRecord` on the entry plus one warning naming the lift verb, and is not counted among the decisions in force; a domain INDEXED from one stays visible as indexed with a contradiction warning; `pinFacts` rides the output. The `sources fields` pair stays on requiredEnumFlags (kickoff instruction; they declare `--type[enum]`). Measured over both real manifests, read-only: 4 `notEnumerableBare` each, all `tenantRecord: excluded` (inert), `undecidedCount` 0, `--require-decided` exit 0 on both, both files byte-identical before and after. (c) the same table carries `scope` for the three commands index-scope-notes names; `report` resolves each stamp's recorded `listCommand` through the catalog (the resolver upsert-batch's fork guard already used) and emits `domains..scope: { key, path, limit } | null` plus a top-level `pinFacts` (T-2 v4, additive, conformance-pinned); nothing stamped per tenant, no backfill, no `--scope` flag — both real manifests read `scope` on their two journey domains today (journey-email-templates and journey-surveys on each; the sandbox no longer carries a journey-data-designer domain and prod excluded that list as coverage). index-scope-notes.md is restructured into one `###` subsection per limited command, headed by the canonical path (= `scope.path`); the A-note on `jo data-designer list` is corrected (a membership signal over data-management, decided as a coverage exclusion, not a domain); check-doc-drift check 23 holds the table's scope ids and the subsections to each other both ways (mutants 23a–e all red: a heading respelled, a subsection with no entry, a subsection outside the section, the table's opener reshaped, the section heading renamed). Adoption tripwires: check-stale-facts FACT_CARRIERS pins the table's stamp to the catalog pin (mutation-proved on the real tree — stamp set to 1.0.8 → red naming CLI_PIN_FACTS; rig: stale stamp red by name with the other carriers green, missing stamp red as an unmatched carrier); test/domain-candidates.mjs holds every table id to the bundled catalog and reds a notEnumerableBare entry the catalog has started to declare (a dead entry). Same class, named as the kickoff asked: `connectors-chains` recorded `modifiedDateStr` on the sandbox and none on prod at the same pin — the cause IS in upsert-batch: `cn chains` at 1.0.9 emits `modifiedDateStr: ""` on every row (three captured pages in the workspace, July, August and September, all four rows blank on each), and the value predicate counted a blank as a resolved date, so the sandbox stored `""` on all four chains (newerThan("", "") is false — never a change, and report counted them as dated) while prod's operator, seeing blanks, recorded none. Fixed: a blank string is present-but-valueless (stored null, `datelessEntries` counts it; `datePresentRows` still counts the path), a full list blank on EVERY row warns once with the `--no-date-field --allow-redate` remedy (present-but-NULL rows stay warning-free — F-392's ruling stands, and the fixture pins the mixed case), and a blank stored before the rule is cleared on the next upsert (`blankDatesCleared`). Prod's recording is the right one; the sandbox's redate is the tester's (VALIDATION § F-450 / F-452, step 4). Second instance, named not changed: `journey-surveys` is `modifiedDate` on prod and `null` on the sandbox, whose list was EMPTY when recorded — a judgment over zero rows, correct by the rules as written. Upstream: the four sublists' undeclared required flags are a CLI manifest gap worth filing with Gainsight (outside this repo). Review round (medium, 8 finders + 5 file-batched verifiers): 14 candidates verified, 8 confirmed and fixed, 2 refuted — the blank-date rule reached only the row loop (a stored blank flipped STALE under adoption and read as dated in report; an all-blank list over stored real dates left the warning claiming "every entry is stored dateless"): ONE `isBlankDate`/`isDateless` predicate now serves the row loop, `newerThan`, the baseline branch (stored blanks cleared before the chain) and report, and the warning names the FROZEN count; `tenantDirOf` read the workspace's own `.gs-superadmin/` as a tenant (the F-307 class inverted); the exact-only caveat never named the file it read, so an unset tenant file that shadowed a good workspace declaration sent the operator to edit the wrong file; the relay had no count rule for an empty scope-limited domain and no rule for ``/``; the canon said "every reader looks for the tenant file first" while audit/change-request/deprecate stayed pack-first (precedence is now tenant file > adopted pack > workspace file, at all four sites); check 23 kept sweeping after its reader failed (three spurious "drop the subsection" lines under mutant 23d) and its slicer ignored fences and level-1 headings (23d now asserts exactly one line; 23f, 23g added); setup's paging bullet, refresh's shortfall guard and upsert-batch's under-count warning still listed the scope-limited commands from memory (retired to the report row + the canon; the F-419 fixture re-pinned to the pointer); the per-pin bucket's `continue` skipped the excluded+blocked contradiction warning (the pairwise checks now run first; a hand-edited both-records manifest is pinned); MAINTAINERS rows for manifest.mjs and tenant-deps.mjs updated. Rides 0.41.0. +Judge: (a) the tester's deps-report walk with a `/CONVENTIONS.md` present on ONE tenant only — the header names the tenant home there and the shared workspace home on the other, and the other tenant never adopts the override's pattern (the leak direction is the fixture's "never completed from the workspace file" arm); (b) the diff over both real manifests re-read by the tester — `notEnumerableBare` 4 with inert records, gate green, no undecided — plus the bundled-catalog table pin; (c) `report` over both manifests reading `scope` on exactly the domains recorded from the three commands and null elsewhere, with no re-list; and the sandbox connectors-chains redate reading `changeDetection: none` with the four chains dateless. VALIDATION § F-450 / F-452 carries the steps. +Sibling sweep: not a registry key. Swept by hand for the class — recordings of a per-CLI fact per tenant: `git grep -n 'dateField\|describeCommand\|itemsPath\|idField' -- plugins/gs-superadmin/scripts/manifest.mjs` — the four stamp recordings are per-tenant by design (a tenant's payload decides them at index time; the redate/rekey guards own their changes), the date-field recording is where this class showed twice (connectors-chains fixed at its cause; journey-surveys named), and no other per-CLI fact is written into a manifest. Cost: minutes. ## F-451 — VERIFIED Reported: 2026-09-10 (tester — first live multi-tenant setup) @@ -493,11 +496,13 @@ Held line by line: the three first-line domains read fresh `none` ("outside chan Verified: 2026-09-15 @ hb-20260915-02 (tester, Session C1-V second round) — every `Not checked` line agreed with its domain's fresh changeDetection, and report-objects landed on the first line. Blind spots (tester, hb-20260915-02): the three legacy-stamp lines (recorded this run as a field; recorded this run as none; still unrecorded) were not produced — no domain on either tenant reads `unrecorded` any more (the C1-V walk recorded the last one), so the step-1-`unrecorded` branches rest on the prose and its check sentence; one refresh on one tenant. -## F-452 — OPEN +## F-452 — FIXED Reported: 2026-09-10 (tester — first live multi-tenant setup) Severity: normal — the confusion lands on the one question Phase 4 asks the user to answer What: Phase 4's closing relay instructs the model to ask "whether the totals match the user's sense of the tenant before Phase 5 spends the documentation budget" — while several relayed counts structurally cannot match, and nothing in the relay says so. A `journey-email-templates` count reads as a tenant total; it is a CLI-reachable subset (the list flattens one folder level and hides some top-level templates). The scope facts exist in `skills/setup/references/index-scope-notes.md` but are never surfaced at the moment the user is asked to validate the numbers. Expected: at the end of Phase 4, scope-limited domains are named in the relay with their limit and the fact that the remainder can be added later — so the "do these totals look right?" question is answerable. +Fix: 2026-09-15 (builder, Session C2 / RP-4, on F-450 c's report field) — setup Phase 4's relay names every scope-limited domain BEFORE the totals question: one line per `domains` row of the report whose `scope` is not null — `: — CLI-reachable subset: ; the remainder can be added later (references/index-scope-notes.md § )` — with `limit` and `path` quoted from the row verbatim (paraphrase-plus-pointer: the reference's subsection headed by that path is the canon, the table's one-liner is the paraphrase, and the relay copies neither from memory), a null-scope row gets no line, and "scope limits not evaluated: " replaces the lines when the table did not apply — never the word none. The relay's exclusion ledger now quotes the diff's `excludedCount` (inert records excluded) and names `notEnumerableBareCount` separately. Prose only beyond F-450's field. Rides 0.41.0. +Judge: the tester's setup walk on the sandbox — the Phase 4 relay must carry one scope line per non-null `scope` row of THAT run's report (two on both tenants today), each quoting the row's limit and path, before the totals question; a relay that asks first, restates a limit from memory, or names a limit for a row whose `scope` is null REOPENS. ## F-453 — OPEN Reported: 2026-09-10 (tester — first live multi-tenant setup) diff --git a/dev/VALIDATION.md b/dev/VALIDATION.md index 6bd5447..951d2a8 100644 --- a/dev/VALIDATION.md +++ b/dev/VALIDATION.md @@ -274,3 +274,46 @@ the PULL-REQUEST suite on the PR head; a `[skip ci]` verdict commit at the tip r `gh workflow run` dispatch does not join the PR rollup, and neither an empty commit nor a close/reopen fired the suite (no check suite was created for a commit with no file changes). This line is the content change that re-runs the suite on the head; nothing else moved. + +## F-450 / F-452 — the C2 arms: scope with no backfill, inert per-pin records, the tenant conventions override, and the sandbox connectors-chains redate (banked 2026-09-15, builder, Session C2) + +Owed by: the tester round on the token the C2 handoff mints (branch round-c2-fact-homes; +PR number on the bus's dev comment). Tenant reads: NONE for the scope arm — `scope` is +derived from each manifest's recorded list commands and the shipped per-pin table, so both +workspaces read it today without re-listing anything. The walks (setup to the Phase 4 +relay; deps-report; refresh if run) read the tenant the way walks always do. The ONE +manifest write is step 4, sandbox only. +1. Read-only, both tenants, from the workspace root: `manifest.mjs report --manifest + /_manifest.json` — `pinFacts.applied` true; `domains..scope` non-null on exactly + the domains recorded from `jo email templates` and `jo surveys list` (builder's local + read: two per tenant, journey-email-templates and journey-surveys; no domain recorded + from `jo data-designer list` remains on either), each `{ key, path, limit }` with `path` + naming a `###` subsection of setup's index-scope-notes.md; every other row `scope: null`. + Then `domain-candidates.mjs diff --manifest /_manifest.json --require-decided`: + exit 0, `notEnumerableBareCount` 4 (the four `re rules` sublists), each + `tenantRecord: "excluded"` with one inert warning apiece, `undecidedCount` 0, + `excludedCount` 17 (the six per-CLI records no longer counted — 21 before). Both + manifests byte-identical before and after (sha256). A scope on any other domain, a + sublist under `undecided`, or a changed manifest REOPENS F-450. +2. Setup walk (slash-only, Bradley types it) on the sandbox, to the Phase 4 relay: the + relay carries the exclusion ledger with the diff's `excludedCount`, names + `notEnumerableBareCount` separately, and lists one scope line per non-null `scope` row — + limit and path quoted from THAT run's report — BEFORE the totals question. A relay that + asks first, restates a limit from memory, or names a limit for a row whose `scope` is null + REOPENS F-452. +3. deps-report walk with the override, one tenant only: copy the workspace + `.gs-superadmin/CONVENTIONS.md` to `/CONVENTIONS.md` and declare a + task-alias prefix THERE only; run deps-report with a `--field` term on the sandbox (the + header reads "from the tenant conventions — the tenant's own CONVENTIONS.md override") + and on prod (exact-only caveat naming the workspace file, or the workspace's own pattern + if one is declared there — never the sandbox's); delete the copy afterwards. A prod run + adopting the sandbox's pattern REOPENS F-450 (a). +4. The connectors-chains redate (sandbox; the one write). First a plain re-upsert of a + fresh `cn chains` capture with the recorded field (no flags): summary + `blankDatesCleared: 4`, `dateResolvedRows: 0`, one warning naming the all-blank shape + and the remedy — the builder measured all four rows carrying `modifiedDateStr: ""` on + three captures (July, August, September). Then `--no-date-field --allow-redate` on the + same capture, and `report`: `domains.connectors-chains.changeDetection` `none`, + `datelessEntries` 4. Prod needs nothing (already recorded none). A summary without + `blankDatesCleared`, or a chain still reading dated after the first upsert, REOPENS + F-450. diff --git a/plugins/gs-superadmin/.claude-plugin/plugin.json b/plugins/gs-superadmin/.claude-plugin/plugin.json index 63150eb..f3a5466 100644 --- a/plugins/gs-superadmin/.claude-plugin/plugin.json +++ b/plugins/gs-superadmin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "gs-superadmin", - "version": "0.40.0", + "version": "0.41.0", "description": "Persistent Gainsight Admin CLI workspace — bootstrap, index, and operate on a Gainsight tenant via gs-admin.", "author": { "name": "BradleyDB" diff --git a/plugins/gs-superadmin/CHANGELOG.md b/plugins/gs-superadmin/CHANGELOG.md index f5162e7..e8e62cd 100644 --- a/plugins/gs-superadmin/CHANGELOG.md +++ b/plugins/gs-superadmin/CHANGELOG.md @@ -5,6 +5,61 @@ marketplace doesn't pin versions — users get main — so entries describe what user who updates, not internal refactors. Entries before 0.8.0 were reconstructed from git history when this file was introduced. +## 0.41.0 — 2026-09-15 + +One decision per kind of fact (F-450): three facts that lived at the wrong scope now each +have one home, and the Phase 4 relay can finally ask an answerable question (F-452). What +a user gets by updating: + +- **Per-CLI facts ship with the plugin, not per tenant.** doc-lib's `CLI_PIN_FACTS` is a + version-stamped table (keyed by catalog command id, applied only while the workspace + catalog is at that pin — the ask-overrides precedent; `check-stale-facts` refuses a + stale stamp) of what the installed CLI does that its catalog does not declare: + - `domain-candidates.mjs diff` reports the four `re rules` sublists (events, + executions, s3-tasks, schedules — list-shaped, no declared required flag, refuse to + run bare) under a new `notEnumerableBare` bucket with the flag each needs, before any + tenant decision: they never gate, and a tenant exclusion or block recorded against one + (the pre-table route: three runtime failures, then an exclusion, re-derived on every + new tenant) is reported inert on the entry and dropped from the decisions in force. + `pinFacts` in the output says whether the table applied; under another pin every + sublist is ordinary undecided work again, with a warning. + - `manifest.mjs report` derives `domains..scope` — `{ key, path, limit }` — for + every domain whose recorded `listCommand` is a scope-limited command (`jo email + templates` flattens a folder level and hides some templates; `jo surveys list` is + PUBLISH-only; `jo data-designer list` is one dataset type — a membership signal over + data-management, not a domain), plus a top-level `pinFacts`. Nothing is recorded per + tenant and nothing is backfilled: existing workspaces read their limits on the next + report. The canon stays prose — one subsection per command in setup's + index-scope-notes.md, headed by the command's canonical path, which `check-doc-drift` + holds to the table both ways. + - The setup Phase 4 relay names every scope-limited domain from `scope` with its + limit and the fact that the remainder can be added later, BEFORE asking whether the + totals look right (F-452: a CLI-reachable subset used to read as a tenant total at the + one moment the user was asked to validate it). +- **CONVENTIONS.md is workspace-wide by default, with a per-tenant override.** The file is + shared by every tenant of the workspace (one company, one set of conventions); a tenant + that differs gets its own `/CONVENTIONS.md` beside its KB, which the deps-report + and email-report scripts read first (an existing tenant file is the home whatever it + says — never completed from the workspace file) and name in the report header and, when + it declares nothing, in the exact-only caveat (so a tenant file that shadows a workspace + declaration is named, not silently in force); the precedence everywhere is tenant file > + adopted `conventions/` pack > workspace file, stated once in the operating model and + followed by the audit, change-request and deprecate prose. The "Tenant-specific" banner + had promised what the readers did not do. +- **A blank date string is not a date.** `upsert-batch` stores an empty-string + modified-date value as null (the row is present-but-valueless, counted by report's + `datelessEntries`), warns once when EVERY row of a full list is blank (`cn chains` at + CLI 1.0.9 emits `modifiedDateStr` as `""`) with the `--no-date-field --allow-redate` + remedy, clears a blank stored before this rule on the next upsert (`blankDatesCleared` + in the summary — before the baseline branch, so adoption over stored blanks baselines + instead of flipping stale), and `report` counts a stored blank as dateless before any + upsert repairs it. Two workspaces had recorded the same command + differently — one the field, one none — and the one carrying the field showed four + "dated" chains that could never change. +- Contract: T-2 v4 — `scope` on every `GsReportDomain` row and `pinFacts` on `GsReport`, + additive, pinned by `test/contract-conformance.mjs`; the diff's output gains + `notEnumerableBare`, `notEnumerableBareCount` and `pinFacts`. + ## 0.40.0 — 2026-09-15 The report is the account of KB state, and it now says how deep that state goes. What a diff --git a/plugins/gs-superadmin/MAINTAINERS.md b/plugins/gs-superadmin/MAINTAINERS.md index 42a220f..2fdb3da 100644 --- a/plugins/gs-superadmin/MAINTAINERS.md +++ b/plugins/gs-superadmin/MAINTAINERS.md @@ -29,8 +29,8 @@ modules they import, `doc-lib.mjs` and `journal-lib.mjs`): | Script | Purpose | |--------|---------| -| `scripts/manifest.mjs` | Deterministic `_manifest.json` operations (init / upsert-batch / mark / next / stub / crawl / report / remove / exclude / block / reconcile-docs) — atomic writes, schema-checked; the skills never hand-edit manifest JSON. `report` is the account of KB state and its output is the T-2 GsReport contract (Session C1): beside the status counts it carries `byDepth` (`full` / `metadata` = stubs awaiting `--deep` / `listOnly` = complete by definition / `unrecorded` = completeness unknown, over documented entries, per domain under `domains.` and in total — F-455), `domainCounts` (`indexed` / `withAssets` / `empty` — F-454), per-domain `changeDetection` (`date` / `none` / `unrecorded`) with `datelessEntries` (F-451), and `docPathsUnknown` (documented entries with no `doc_path`, per domain and in total — F-459); `byDomain` rows stay numbers-only because describe-batch sums them. `mark --status documented` refuses an entry with no recorded `doc_path` when none is passed (F-459), `remove` reports `docPathsUnknown` beside `docPaths`, and `reconcile-docs --domain ` backfills `doc_path` from the docs on disk through the writers' own naming (doc-lib `docBaseName` + the claimer's `-dup` chain), naming recorded-missing, unmatched and orphan files — the reconcile-on-resume step issue #12's batched marks will reuse. `upsert-batch` records each domain's id field, describe recipe, list command (`--list-command`, matched by the candidate gate), and modified-date field (`--date-field` / `--no-date-field`) in `domains_indexed` and reuses the recordings on re-runs, guarded against contradiction (`--allow-rekey` / `--allow-redate` override deliberate scheme changes); its summary warns on probable under-pagination (incoming list smaller than the domain's non-failed inventory) and on mass-stale flips — it never removes entries itself; `--partial` declares a deliberate-subset registration (gap-fill flows, F-313), which skips the under-pagination warning and leaves the domain's coverage stamp untouched. `exclude` / `block` persist the candidate gate's per-command decisions ("looked and said no" vs "could not look" — F-108/F-218), keyed by canonical catalog path; an exclusion records its evidence (F-449): exactly one of `--check ` (the candidate's own `check --out` file, whose `command` must match) or `--no-check ""`, with `--covered-by ` as the coverage claim the check's numbers must support (and must be made when they do), and `--recheck-after` on both record kinds | -| `scripts/domain-candidates.mjs` | The setup Phase 4 candidate gate (F-108), read-only: `diff` derives every list-shaped tenant-wide command from the catalog and maps each to indexed / excluded / blocked / undecided against the manifest's recordings (`--require-decided` exits 1 while any candidate is undecided or a domain lacks a `listCommand` recording); `check` answers the GLOBAL "are these rows already indexed anywhere" overlap question over a captured list payload (fails loudly on unresolvable id fields, partial extractions, and — without `--allow-empty` — payloads with no items array), and with `--command "" --out ` writes its JSON as the evidence file `manifest.mjs exclude --check` reads (F-449); the diff's `excluded` rows carry `kind` (coverage / judgment / no-check / legacy), the evidence, and `recheckDue` | +| `scripts/manifest.mjs` | Deterministic `_manifest.json` operations (init / upsert-batch / mark / next / stub / crawl / report / remove / exclude / block / reconcile-docs) — atomic writes, schema-checked; the skills never hand-edit manifest JSON. `report` is the account of KB state and its output is the T-2 GsReport contract (Session C1): beside the status counts it carries `byDepth` (`full` / `metadata` = stubs awaiting `--deep` / `listOnly` = complete by definition / `unrecorded` = completeness unknown, over documented entries, per domain under `domains.` and in total — F-455), `domainCounts` (`indexed` / `withAssets` / `empty` — F-454), per-domain `changeDetection` (`date` / `none` / `unrecorded`) with `datelessEntries` (F-451), `scope` (`{ key, path, limit }` or null — the list command's scope limit at the pin, derived from doc-lib's `CLI_PIN_FACTS` through the stamp's recorded `listCommand`, never recorded per tenant; plus a top-level `pinFacts` saying whether the table applied — F-450), and `docPathsUnknown` (documented entries with no `doc_path`, per domain and in total — F-459); `byDomain` rows stay numbers-only because describe-batch sums them. `mark --status documented` refuses an entry with no recorded `doc_path` when none is passed (F-459), `remove` reports `docPathsUnknown` beside `docPaths`, and `reconcile-docs --domain ` backfills `doc_path` from the docs on disk through the writers' own naming (doc-lib `docBaseName` + the claimer's `-dup` chain), naming recorded-missing, unmatched and orphan files — the reconcile-on-resume step issue #12's batched marks will reuse. `upsert-batch` records each domain's id field, describe recipe, list command (`--list-command`, matched by the candidate gate), and modified-date field (`--date-field` / `--no-date-field`) in `domains_indexed` and reuses the recordings on re-runs, guarded against contradiction (`--allow-rekey` / `--allow-redate` override deliberate scheme changes); its summary warns on probable under-pagination (incoming list smaller than the domain's non-failed inventory) and on mass-stale flips — it never removes entries itself; `--partial` declares a deliberate-subset registration (gap-fill flows, F-313), which skips the under-pagination warning and leaves the domain's coverage stamp untouched. `exclude` / `block` persist the candidate gate's per-command decisions ("looked and said no" vs "could not look" — F-108/F-218), keyed by canonical catalog path; an exclusion records its evidence (F-449): exactly one of `--check ` (the candidate's own `check --out` file, whose `command` must match) or `--no-check ""`, with `--covered-by ` as the coverage claim the check's numbers must support (and must be made when they do), and `--recheck-after` on both record kinds | +| `scripts/domain-candidates.mjs` | The setup Phase 4 candidate gate (F-108), read-only: `diff` derives every list-shaped tenant-wide command from the catalog and maps each to indexed / excluded / blocked / undecided against the manifest's recordings — after setting aside `notEnumerableBare`, the commands doc-lib's version-stamped `CLI_PIN_FACTS` says the pinned CLI refuses to run bare (F-450; a tenant record against one is reported inert, never counted) — (`--require-decided` exits 1 while any candidate is undecided or a domain lacks a `listCommand` recording); `check` answers the GLOBAL "are these rows already indexed anywhere" overlap question over a captured list payload (fails loudly on unresolvable id fields, partial extractions, and — without `--allow-empty` — payloads with no items array), and with `--command "" --out ` writes its JSON as the evidence file `manifest.mjs exclude --check` reads (F-449); the diff's `excluded` rows carry `kind` (coverage / judgment / no-check / legacy), the evidence, and `recheckDue` | | `scripts/describe-batch.mjs` | Sanctioned describe→doc→mark loop for one domain: selects the batch via `manifest.mjs next`, runs the describes sequentially with `{id}`/`{name}` substituted as literal argv (no shell — pipe-bearing names are safe; resolves the CLI's JS entry, so no Windows `.cmd` traps), writes one structured doc per asset, and marks each as it lands, emitting a stderr progress line every few describes plus a `domainProgress` summary field (documented/total; on `--upgrade` runs `{ full, metadata, failed, total }`, since stubs already count as documented). `--command` defaults to the describe recipe the domain's index recorded (`manifest.mjs upsert-batch --describe-command`); an explicit `--command` wins. For the domain recorded from `jo email templates` it auto-selects its template doc-mode (`--doc-mode template`) and for the one recorded from `jo programs list` its program doc-mode (`--doc-mode program`) — the recording decides, the naming rule's `journey-email-templates` / `journey` only as the no-recording fallback (F-429; the lane table is doc-lib's `RECORDED_LANES`) — writing compact docs instead of raw-JSON docs — rendering shared with `template-doc.mjs`/`program-doc.mjs` via `scripts/doc-lib.mjs`. Records a content fingerprint of each documented payload (volatile modified/updated fields dropped), and `--if-changed` uses it as a re-document gate: an unchanged payload skips the doc write and is just re-marked documented (`skippedUnchanged` in the summary), so a platform event that bumps modified dates en masse doesn't rewrite every doc. Enforces read-only fail-closed against the catalog — mutating or unknown commands are refused, recorded or passed. For `data-designer` it auto-selects its designer doc-mode (`--doc-mode designer`, GP-B5 W9): three describe levels per template — the template, one `--task-id` drilldown per task, one `--field` detail per show-field label (labels derived from the drilldown's tables by doc-lib's `designerTaskFieldLabels`, aggregation suffix stripped per its enumerated vocabulary; both flag spellings verified against the catalog before any spawn) — composed into ONE doc by doc-lib's `renderDesignerDoc` (the template payload with the drilldowns under `_kb`, T-3 v5). Cost is bounded per invocation by `--spawn-budget` (default 30 calls) and the doc is rewritten after every call, so a run cut off mid-template leaves an INCOMPLETE doc that the next invocation resumes from (`designerDocProgress`); the entry is marked documented only when every item is ok, failed (doc kept, doc_path recorded) when any item failed after all were attempted, and `--if-changed` skips only a COMPLETE composite of unchanged content. Within-run abort (issue #13, F-458): five consecutive `failed` marks stop the domain with the marks standing, and a FAILED spawn carrying the CLI's re-login sentence (doc-lib `isAuthDeath`, version-stamped) stops it on the first sighting with nothing marked (a designer entry keeps its composite with the item pending); either is reported as one ADDITIVE summary field `aborted: { reason: "consecutive-failures" | "auth", after, lastError }` — absent on a run that did not stop early; `budgetExhausted`, `moreRemaining` and `failures` keep their shapes (the setup skill branches on them: contract) | | `scripts/capture.mjs` | The shipped capture helper (GP-B5 DS-17): runs one read-only `gs-admin` command (argv array, no shell — the CLI's JS entry resolved the same way as `describe-batch.mjs`) and BYTE-copies its stdout to `--out` as UTF-8 **without a BOM** via temp+rename (no decode — the child's bytes are preserved exactly) — the redirect-encoding rule the capturing skills used to restate as prose, carried by construction; `--normalize ` re-encodes an existing capture in place, tolerant of what PS 5.1 redirects write (UTF-16LE/BE with BOM, BOM'd UTF-8) and REFUSING, file untouched, anything whose decode would be lossy (BOM-less non-UTF-8 console-codepage captures, lone-surrogate UTF-16). Spawns the CLI itself, so the mutation guard never sees the embedded command: it gates fail-closed through doc-lib's shared `assertPlainGsAdminCommand` + `assertReadOnlyCommand` (non-gs-admin / shell-operator / unknown / catalog-mutating / ask-override / read-shape / write-endpoint all refused) with a capture-shaped policy built on doc-lib's shared `isDescribeRead` (describe-shaped by catalog `actionKey` or by the path's trailing word, plus the `READ_VERB_EXACT` per-item reads — F-456) and list shapes by verb, actionKey, or summary (both of `domain-candidates.mjs`'s prongs), and `dm deps check`. A failed child propagates its exit code and writes nothing — an earlier capture at `--out` is left intact and named as earlier. `--wait` (GP-B5 DS-26) bounded-polls the async `dm deps check` scan: re-run every `--wait-interval` s (default 15, floor 1 — each attempt is a real tenant request) up to `--wait-timeout` s (default 120, honored in full), writing only a payload whose `data.progressStatus.overallStatus` is COMPLETED — the envelope requirement is exactly the readers' rule, differential-locked against `parseLiveDepsAreas` in the suite; on timeout NOTHING is written and the non-zero exit names the elapsed time and last status ("not ready after N s", never a confident zero). `--paginate` (GP-B5 DS-30) owns the list-sweep pagination doctrine: `--page-flag ` (the command's paging flag, appended per round — never guessed; `none` = one reconciled fetch), a `{page}` placeholder in `--out` (one file per page), `--max-pages` safety stop; each page's envelope is scanned with doc-lib's `scanListEnvelope` (er-count's no-descend traversal + parse-don't-validate totals over the measured envelope variance) and the summary is the honesty report — pages fetched vs parsed, rows counted, every total with its path, and a verdict, with only `reconciled`/`unverified` exiting 0 (`mismatch`/`suspect`/`total-conflict`/`failed-page`/`safety-stop` exit non-zero; an unparseable page is a failed sweep page kept as evidence, never 0 entries). `build/check-doc-drift.mjs` check 14 enforces that skill captures route through this helper | | `scripts/relationships-build.mjs` | Sanctioned Phase 6 map generator: derives `relationships/field-to-rule.md`, `field-to-scorecard.md`, `process-maps.md`, and `program-to-template.md` (program → email template via GSID co-occurrence; there is deliberately no journey→rule map — rules never feed program participants) from the KB's full-describe docs (`_flatMappings` semantics and their verification basis documented in the script header) — coverage headers per file, unknown actionTypes reported rather than dropped, dangling measure references flagged | @@ -39,7 +39,7 @@ modules they import, `doc-lib.mjs` and `journal-lib.mjs`): | `scripts/jo-report.mjs` (+ `scripts/jo-report-search.mjs` / `scripts/jo-report-program.mjs` / `scripts/jo-report-audit-active.mjs` / `scripts/jo-report-deps.mjs`) | Engine behind `/gs-superadmin:email-report`: parses KB journey + email-template docs (both doc generations, both stepJson shapes; failures collected per doc, never fatal), builds the local index with optional live-status overlay (drift / live-only / KB-only detection) — including normalized token bindings + bound surveys per email step (both generations) and template token metadata — and dispatches to the four report modes by dynamic import; a shared token resolver renders `${...}` ids as display names on every surface, and shared markdown/CSV plumbing guarantees every report carries the "Caveats & data gaps" section and never overwrites an earlier one | | `scripts/er-count.mjs` | Email-report page-entry counter (GP-B5 DS-29): prints the largest array length in a captured list-page payload — the step-2 sweep's stop signal — without the payload entering model context. Arrays are measured, never descended into (an entry's own nested arrays must not outvote the entry array). BOM-tolerant read; a missing/unreadable/malformed page refuses loudly with exit 1 naming the script, so it can never read as "0 entries" and end the sweep early. Formerly an inline transcription the email-report skill wrote to `.gs-superadmin/tmp/` on every run | | `scripts/er-gaps.mjs` | Email-report gap work-list builder (GP-B5 DS-29): reads the step-3 index (`.gs-superadmin/tmp/er-index.json`) plus the workspace manifest and writes the step-4c work lists (`er-gap-stale/liveonly/templates/tokenless[-orphans].json`) in the exact shapes `manifest.mjs mark --keys-file` / `upsert-batch --file` consume — keyed by each domain's RECORDED idField, TTL-stale keys oldest-first so `--limit` cuts the budget in file order on disk, never through model context; the ER-15 tokenless list is joined against the manifest (orphan docs counted separately, never batched — mark's all-or-nothing contract must not refuse the whole backfill). Prints a one-line summary JSON (idFields + per-category counts + total); unreadable index/manifest or a bad TTL refuses loudly instead of emitting a confidently empty work list. Formerly an inline transcription in the same skill | -| `scripts/tenant-deps.mjs` | Engine behind `/gs-superadmin:deps-report`: scans the KB docs of all dependent domains directly (no index artifact) for object/field/connection usage — rules (task source objects + connection types, criteria filter conditions, `_flatMappings` write targets, action-mapping connection ids), journeys (via the jo-report parsers), reports (sourceDetails, show/group/order fields incl. calculated nesting, where/having filters), connector jobs, data designers, journey datasets — resolves connection terms through the connector docs' registry (name/id/type, exact vs `type-level` matches), links scorecard measures set by matching rules, and reconciles captured all-areas `dm deps check` payloads (`--live-deps`) against the KB view. Terms arrive as repeatable `--object`/`--field`/`--connection` flags or as `--terms-file ` (GP-B5 DS-28) — the skill's shell-neutral on-disk work list (`{ "objects"?, "fields"?, "connections"? }`, BOM-tolerant, merged with inline flags; unknown keys and non-string entries refuse the whole file loudly). The field-aliasing convention is read from the workspace CONVENTIONS.md by the script itself (GP-B5 DS-27; `--alias-prefix` overrides, `''` disables). Data-designer docs written by the designer doc-mode (GP-B5 W9) yield per-field rows from their `_kb` composite — system field name, label, alias, the field's own source object and connection from the `--field` detail; criteria / join-condition rows resolved through the same details — with per-row honesty when a detail is missing (`fieldNamesUnavailable` + a reason from `DESIGNER_BLIND_REASONS`: summary shape, drilldown missing/failed, duplicate label, unparsed join field list, label-only carrier), and the report's caveat breaks the blind count down by reason | +| `scripts/tenant-deps.mjs` | Engine behind `/gs-superadmin:deps-report`: scans the KB docs of all dependent domains directly (no index artifact) for object/field/connection usage — rules (task source objects + connection types, criteria filter conditions, `_flatMappings` write targets, action-mapping connection ids), journeys (via the jo-report parsers), reports (sourceDetails, show/group/order fields incl. calculated nesting, where/having filters), connector jobs, data designers, journey datasets — resolves connection terms through the connector docs' registry (name/id/type, exact vs `type-level` matches), links scorecard measures set by matching rules, and reconciles captured all-areas `dm deps check` payloads (`--live-deps`) against the KB view. Terms arrive as repeatable `--object`/`--field`/`--connection` flags or as `--terms-file ` (GP-B5 DS-28) — the skill's shell-neutral on-disk work list (`{ "objects"?, "fields"?, "connections"? }`, BOM-tolerant, merged with inline flags; unknown keys and non-string entries refuse the whole file loudly). The field-aliasing convention is read by the script itself — from the tenant's own `/CONVENTIONS.md` when that file exists (it overrides), else the workspace CONVENTIONS.md; the header names which (GP-B5 DS-27, F-450 a; `--alias-prefix` overrides, `''` disables). Data-designer docs written by the designer doc-mode (GP-B5 W9) yield per-field rows from their `_kb` composite — system field name, label, alias, the field's own source object and connection from the `--field` detail; criteria / join-condition rows resolved through the same details — with per-row honesty when a detail is missing (`fieldNamesUnavailable` + a reason from `DESIGNER_BLIND_REASONS`: summary shape, drilldown missing/failed, duplicate label, unparsed join field list, label-only carrier), and the report's caveat breaks the blind count down by reason | | `scripts/journal.mjs` | Change attribution + completion journaling for the change-request skill (`change-start` / `change-end` / `journal-append`, kind literal `change-plan execution`) — validates ticket/plan/slug, stamps timestamps and operator itself, and writes `JOURNAL.md` through the shared emitters in `scripts/journal-lib.mjs`: the file header (a guard-fixtures check locks both writers' runtime headers equal) and the T-4 entry frame (contract-conformance's T-4 section pins every entry line through both writers) | | `scripts/extract-catalog.mjs` | Generates the workspace catalog from the installed CLI's `dist/artifacts/*.json` — setup's primary catalog source on every run; the bundled `reference/catalog.json` is the fallback when generation fails (verbatim copy of the docs-repo extractor) | | `scripts/plugin-link.mjs` | The workspace plugin-link writer (GP-B5 DS-43): maintains ONE directory link, `.gs-superadmin/plugin` → the loaded plugin's root (an NTFS junction on Windows, a directory symlink elsewhere — the one platform branch), through which every skill fence addresses bundled scripts as `node .gs-superadmin/plugin/scripts/.mjs …`. Run by the `SessionStart` hook (`--hook`, exit 0 always, reports through `hookSpecificOutput.additionalContext`) at every plugin session start, and by setup's first-run fence (CLI mode: one stdout line naming the target, non-zero on failure). The target is derived from the script's OWN real path — never from `CLAUDE_PLUGIN_ROOT` in the environment or anything in the workspace; a stale or dangling link is repointed with `unlinkSync` (the only removal call), a real directory at the link path is refused, never deleted; inert without a `.gs-superadmin/` directory in cwd. Belt: reports (never fails) when the link is not git-ignored inside a git work tree; names a OneDrive-rooted workspace on Windows | diff --git a/plugins/gs-superadmin/README.md b/plugins/gs-superadmin/README.md index 11b4b6c..b4d4d35 100644 --- a/plugins/gs-superadmin/README.md +++ b/plugins/gs-superadmin/README.md @@ -447,7 +447,9 @@ editing session has unsaved changes (the Journey cache is keyed by program id, n `node .gs-superadmin/plugin/scripts/.mjs`; created by setup, repointed at every plugin session start, never a copy operating-model.md Claude's always-on instructions (yours to edit) - CONVENTIONS.md Build standards entry point (yours to fill in) + CONVENTIONS.md Build standards entry point (yours to fill in; workspace-wide — + a `/CONVENTIONS.md` beside a tenant's KB overrides it + for that tenant alone) conventions/ Opt-in build-standards pack (yours to edit or delete) catalog.json Generated from the installed CLI's manifests (bundled snapshot if generation fails) diff --git a/plugins/gs-superadmin/scripts/doc-lib.mjs b/plugins/gs-superadmin/scripts/doc-lib.mjs index f98675a..f6a07db 100644 --- a/plugins/gs-superadmin/scripts/doc-lib.mjs +++ b/plugins/gs-superadmin/scripts/doc-lib.mjs @@ -1578,6 +1578,95 @@ export function recordedDomainsByPath(domainsIndexed, resolveLine) { return { byPath, legacyDomains, unresolved }; } +// ── Per-pin CLI facts (F-450): what the installed CLI DOES that its own +// catalog does not DECLARE. Two kinds, one table, one version stamp: +// notEnumerableBare — a list-shaped command whose handler refuses to run +// without a per-asset flag the artifact manifest never marks required +// (the four `re rules` sublists at 1.0.9: they pass the candidate +// filter, then fail at runtime with the message recorded here). Before +// this table every tenant's operator re-derived the same fact by running +// each one three times and recording a per-tenant exclusion — a per-CLI +// fact stored per tenant (F-450 b). The diff names them in their own +// bucket and never asks for a decision. +// scope — a list command that cannot see the whole tenant (its handler +// hardcodes a filter or flattens a tree): the count it yields is a +// CLI-reachable SUBSET, and `report` says so on the domain row +// (F-450 c) so the Phase 4 relay can name the limit (F-452). The one-line +// `limit` here is the paraphrase; the canon stays prose — ONE `###` +// subsection per entry under "Scope-limited domains" in +// skills/setup/references/index-scope-notes.md, headed by the command's +// canonical path, which build/check-doc-drift.mjs holds to this table +// both ways (read out of this source text — the build lane may not +// import the plugin). +// Keyed by catalog command `id` (namespace:group…:action) — NOT actionKey: +// `list-source-fields` is the actionKey of two commands in two namespaces. +// SELF-RETIRING, the ask-overrides.json precedent (tenet 6): the table +// applies only while the catalog in use carries this exact cliVersion; +// under any other pin every reader gets an empty table plus a warning, and +// build/check-stale-facts.mjs (FACT_CARRIERS) refuses the stamp until a +// human re-verifies each entry against the new package — the pair below is +// the adoption-time tripwire. test/domain-candidates.mjs holds every id to +// the bundled catalog and refuses a notEnumerableBare entry the catalog has +// started to declare (a dead entry). +/** + * @typedef {object} GsPinFact one entry of CLI_PIN_FACTS.commands — at least one of + * the two kinds is present + * @property {string} [notEnumerableBare] the per-asset flag(s) the CLI demands at runtime + * @property {string} [error] the CLI's runtime message, quoted + * @property {string} [scope] one-line paraphrase of the list command's scope + * limit (the canon is the notes subsection) + */ +/** @type {Readonly<{ cliVersion: string, commands: Readonly>> }>} */ +export const CLI_PIN_FACTS = Object.freeze({ + cliVersion: "1.0.9", + commands: Object.freeze({ + "rules-engine:rules:events": Object.freeze({ + notEnumerableBare: "--topic ", + error: "--topic is required", + }), + "rules-engine:rules:executions": Object.freeze({ + notEnumerableBare: "--rule-id or --rule-name ", + error: "ruleId or ruleName is required", + }), + "rules-engine:rules:s3-tasks": Object.freeze({ + notEnumerableBare: "--rule-id or --rule-name ", + error: "ruleId or ruleName is required", + }), + "rules-engine:rules:schedules": Object.freeze({ + notEnumerableBare: "--id or --name ", + error: "--id or --name is required", + }), + "journey:email:templates": Object.freeze({ + scope: "flattens one folder level (templates in nested subfolders are dropped) and hides some top-level templates; the count is the CLI-reachable subset", + }), + "journey:surveys:list": Object.freeze({ + scope: "returns PUBLISH-state surveys only (closed surveys are invisible)", + }), + "journey:data-designer:list": Object.freeze({ + scope: "returns one dataset type only (ds=UNIVERSAL_DATA_SET); its rows are Data Designer OUTPUT datasets, a membership signal over data-management, not a domain of their own", + }), + }), +}); +/** + * The per-pin facts IN FORCE for one catalog: the table when the catalog's + * cliVersion equals the stamp, an empty table (with the why) otherwise — + * readers apply `commands` blindly and surface `why` when `applied` is + * false, so a pin move never silently drops the facts (A-4). + * @param {{meta?: {cliVersion?: string}} | null | undefined} catalog + * @returns {{ stamped: string, catalogVersion: ?string, applied: boolean, why: ?string, commands: Readonly>> }} + */ +export function pinFactsFor(catalog) { + const stamped = CLI_PIN_FACTS.cliVersion; + const catalogVersion = typeof catalog?.meta?.cliVersion === "string" ? catalog.meta.cliVersion : null; + const applied = catalogVersion === stamped; + const why = applied + ? null + : catalogVersion == null + ? `the catalog carries no meta.cliVersion, so the per-pin CLI facts (stamped for ${stamped}) were not applied` + : `the per-pin CLI facts are stamped for ${stamped} and the catalog is ${catalogVersion} — not applied; re-verify doc-lib's CLI_PIN_FACTS against the installed package (check-stale-facts names the stamp)`; + return { stamped, catalogVersion, applied, why, commands: applied ? CLI_PIN_FACTS.commands : Object.freeze({}) }; +} + // ── The lane table: the ONE home of "which list command is a lane, and what its // folder is called when nothing is recorded" (F-429 fourth pass). Every reader // derives its lanes and defaults from here — tenant-deps, the relationships diff --git a/plugins/gs-superadmin/scripts/domain-candidates.mjs b/plugins/gs-superadmin/scripts/domain-candidates.mjs index df705ae..1909236 100644 --- a/plugins/gs-superadmin/scripts/domain-candidates.mjs +++ b/plugins/gs-superadmin/scripts/domain-candidates.mjs @@ -42,6 +42,19 @@ // it BY NAME on every run, with its reason and suggested // name, and warns when its recheckAfter date has passed. // undecided — none of the above: the gate's work list +// and, BEFORE the states above, one bucket that is not a decision: +// notEnumerableBare — the installed CLI refuses to run the command +// without a per-asset flag its catalog never declares +// (doc-lib CLI_PIN_FACTS, F-450 b; the four `re rules` +// sublists at 1.0.9). A per-CLI fact, so no tenant decides +// it: listed with the flag it needs and the runtime error, +// never undecided, never gating. A tenant exclusion or +// block recorded against one (the pre-table route) is +// reported INERT on the entry (`tenantRecord`) with a +// warning, and is not counted among the decisions in +// force. The table is version-stamped and self-retiring: +// under a catalog at another pin it is not applied and the +// diff warns (`pinFacts` in the output says which). // Undecided candidates get a suggested KB domain name // (-) collision-checked // against existing domains and bare namespaces — a renamed domain @@ -106,6 +119,7 @@ import { makeCommandResolver, recordedDomainsByPath, indexedElsewhere, + pinFactsFor, } from "./doc-lib.mjs"; const here = dirname(fileURLToPath(import.meta.url)); @@ -184,6 +198,10 @@ if (verb === "diff") { const candidateByPath = new Map(candidates.map((c) => [c.path, c])); const warnings = []; + // Per-pin CLI facts (F-450 b): applied only at the stamped pin, warned + // about otherwise — a pin move never silently drops them. + const pin = pinFactsFor(catalog); + if (!pin.applied) warnings.push(pin.why); const domainsIndexed = manifest.domains_indexed && typeof manifest.domains_indexed === "object" ? manifest.domains_indexed : {}; // canonical path → [domain names]: doc-lib's recordedDomainsByPath (F-429 @@ -324,6 +342,7 @@ if (verb === "diff") { const indexed = []; const excluded = []; const blocked = []; + const notEnumerableBare = []; const undecided = []; const today = new Date().toISOString().slice(0, 10); // ONE due-date rule for both record kinds (excluded and blocked carry @@ -346,6 +365,10 @@ if (verb === "diff") { // the enumeration with a DECISION_RECORDS table driving a generated pair // loop (and derive the precedence chain from the same table), or a // forgotten pair silently reports a contradictory manifest as clean. + // The per-pin bucket below is NOT a record kind — it is a catalog-level + // fact that pre-empts the precedence chain — so these checks run first + // and a contradictory manifest is still named whatever bucket the + // command lands in (review round). if (inDomains && excl) { warnings.push( `"${c.path}" is both indexed (domain ${inDomains.join(", ")}) and excluded — contradictory; ` + @@ -364,6 +387,41 @@ if (verb === "diff") { `look"); treating it as excluded — lift whichever record is wrong` ); } + // Per-pin fact (F-450 b): a command the installed CLI refuses to run bare + // is not a decision for any tenant — its own bucket, before the record + // chain. A tenant record written against it (the pre-table route: three + // runtime failures, then an exclusion) is INERT and says so; it is not a + // decision in force and never counts as one. An INDEXED domain from such + // a command contradicts the table (or was registered from a per-asset + // run): warn and let the chain below report it as indexed — a wrong + // table entry must stay visible, never hide a real domain. + const pinFact = typeof c.id === "string" && Object.hasOwn(pin.commands, c.id) ? pin.commands[c.id] : null; + if (pinFact?.notEnumerableBare && inDomains) { + warnings.push( + `"${c.path}" is indexed (domain ${inDomains.join(", ")}) but the per-pin table says it is not enumerable bare at CLI ${pin.stamped} ` + + `(needs ${pinFact.notEnumerableBare}) — either the table entry is wrong (re-verify it against the installed CLI) or the domain ` + + `was registered from a per-asset run; reported as indexed` + ); + } else if (pinFact?.notEnumerableBare) { + const tenantRecord = excl ? "excluded" : blk ? "blocked" : null; + if (tenantRecord) { + warnings.push( + `"${c.path}" is ${tenantRecord} per tenant, but at CLI ${pin.stamped} it is not enumerable bare (needs ${pinFact.notEnumerableBare}) — ` + + `the record is inert: no decision is needed; lift it (${tenantRecord === "excluded" ? "exclude" : "block"} --remove) or leave it` + ); + } + notEnumerableBare.push({ + path: c.path, + shortPath: c.shortPath ?? null, + namespace: c.domain, + actionKey: c.actionKey ?? null, + summary: c.summary ?? null, + needs: pinFact.notEnumerableBare, + error: pinFact.error ?? null, + tenantRecord, + }); + continue; + } if (inDomains) { indexed.push({ path: c.path, shortPath: c.shortPath ?? null, domains: [...inDomains].sort() }); } else if (excl) { @@ -415,6 +473,7 @@ if (verb === "diff") { excluded.sort(cmpPath); blocked.sort(cmpPath); undecided.sort(cmpPath); + notEnumerableBare.sort(cmpPath); legacyDomains.sort(); // Gate verdict first, summary last: the summary is the stdout write, and the @@ -454,7 +513,12 @@ if (verb === "diff") { excludedLegacyCount: legacyExclusions, blockedCount: blocked.length, undecidedCount: undecided.length, + // F-450 b: a per-CLI bucket, never gating; pinFacts says whether the + // shipped table applied to this catalog at all. + notEnumerableBareCount: notEnumerableBare.length, + pinFacts: { stamped: pin.stamped, catalogVersion: pin.catalogVersion, applied: pin.applied }, undecided, + notEnumerableBare, indexed, excluded, blocked, diff --git a/plugins/gs-superadmin/scripts/jo-report-deps.mjs b/plugins/gs-superadmin/scripts/jo-report-deps.mjs index 8ffc5b3..21e1bcd 100644 --- a/plugins/gs-superadmin/scripts/jo-report-deps.mjs +++ b/plugins/gs-superadmin/scripts/jo-report-deps.mjs @@ -30,7 +30,8 @@ // matched) are listed in a "possible related fields (not counted)" caveat. // The convention is read by the SCRIPT itself (GP-B5 DS-27: // readAliasConvention below, from the workspace's -// .gs-superadmin/CONVENTIONS.md, walking up from the KB dir); +// .gs-superadmin/CONVENTIONS.md, walking up from the KB dir — or, for one +// tenant that differs, its own /CONVENTIONS.md override, F-450 a); // --alias-prefix is the explicit override. Unset → exact-only (the S3 // behavior above) plus a no-convention caveat; malformed → exact-only plus // a loud caveat naming why — a pattern is never inferred from tenant data @@ -96,7 +97,7 @@ // Zero dependencies — Node built-ins only. // ───────────────────────────────────────────────────────────────────────────── import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { parseFlags, C3_REPEATABLE, @@ -160,7 +161,12 @@ export function compileAliasPrefix(source) { // ── tenant conventions read (GP-B5 DS-27) ──────────────────────────────────── // The field-aliasing convention lives in the workspace's -// `.gs-superadmin/CONVENTIONS.md`: a `## Field aliasing` section whose +// `.gs-superadmin/CONVENTIONS.md` — the default for every tenant of the +// workspace — or, for one tenant that differs, in a `/CONVENTIONS.md` +// override beside its KB (F-450 a, ruled 2026-09-15: one company, one set of +// conventions is the norm; the per-tenant file is the exception). Every +// result carries `home` ("tenant" | "workspace") naming which was read, and +// `path`. It is a `## Field aliasing` section whose // `- task-alias-prefix-regex:` bullet carries the pattern as ONE inline-code // value (the shipped templates/CONVENTIONS.md shape). Both deps surfaces read // it through here. The guardrail that used to be skill prose is behavioral @@ -185,12 +191,45 @@ export function readAliasConvention(startDir) { // to be the walk's third hand copy). const wsDir = findWorkspaceDir(startDir); if (!wsDir) return { status: "unset", why: "no .gs-superadmin workspace at or above the KB directory" }; - const path = join(wsDir, ".gs-superadmin", "CONVENTIONS.md"); + // Home (F-450 a): the tenant directory is the KB path's first segment + // under the workspace (`//…`); its CONVENTIONS.md, when it + // EXISTS, is the home whatever it contains — an unset or malformed + // declaration is reported against it, never silently completed from the + // workspace file (two files must never read as one declaration). Only a + // MISSING tenant file falls through to the workspace default. A start + // directory AT the workspace root has no tenant and reads the default. + const tenantDir = tenantDirOf(startDir, wsDir); + const tenantPath = tenantDir ? join(tenantDir, "CONVENTIONS.md") : null; + const home = tenantPath && existsSync(tenantPath) ? "tenant" : "workspace"; + const path = home === "tenant" ? tenantPath : join(wsDir, ".gs-superadmin", "CONVENTIONS.md"); + return { ...readAliasConventionFile(path), home }; +} +// The ancestor of startDir whose parent is the workspace dir — the tenant +// (slug) directory — or null when startDir IS the workspace dir, lies outside +// it, or sits under the workspace's own `.gs-superadmin/` control directory +// (review round: that directory is a first-level child too, and its +// CONVENTIONS.md IS the workspace default — reporting it as a tenant override +// would be the F-307 false-provenance class inverted). +function tenantDirOf(startDir, wsDir) { + const ws = resolve(String(wsDir)); + let dir = resolve(String(startDir)); + if (dir === ws) return null; + const control = join(ws, ".gs-superadmin"); + for (;;) { + const up = dirname(dir); + if (up === ws) return dir === control ? null : dir; + if (up === dir) return null; + dir = up; + } +} +// One CONVENTIONS.md file, parsed — the grammar below is unchanged by the +// home rule; every result carries the path it was read from. +function readAliasConventionFile(path) { let text; try { text = normalizeText(readFileSync(path, "utf8")); } catch { - return { status: "unset", why: "no readable CONVENTIONS.md in the workspace", path }; + return { status: "unset", why: "no readable CONVENTIONS.md in the tenant directory or the workspace", path }; } // Section: from the `## Field aliasing` heading (title matched // case-insensitively) to the next level-1/level-2 heading. The TITLE is the anchor, @@ -378,7 +417,7 @@ export function resolveAliasPrefix({ explicit, hasFieldTerms, kbDir }) { if (!hasFieldTerms) return { source: null, origin: null, prefix: null, note: null, unsetWhy: null }; const conv = readAliasConvention(kbDir); if (conv.status === "declared") - return { source: conv.pattern, origin: "convention", prefix: conv.prefix, note: null, unsetWhy: null }; + return { source: conv.pattern, origin: "convention", prefix: conv.prefix, note: null, unsetWhy: null, home: conv.home, path: conv.path }; if (conv.status === "malformed") return { source: null, @@ -389,7 +428,7 @@ export function resolveAliasPrefix({ explicit, hasFieldTerms, kbDir }) { `field-aliasing convention in ${conv.path} is MALFORMED (${conv.why}) — field matching ran EXACT-ONLY; ` + `fix the declaration or pass --alias-prefix explicitly (a pattern is never guessed from tenant data)`, }; - return { source: null, origin: null, prefix: null, note: null, unsetWhy: conv.why }; + return { source: null, origin: null, prefix: null, note: null, unsetWhy: conv.why, home: conv.home ?? null, path: conv.path ?? null }; } // Space↔underscore separator equivalence (ruled P-2: `X_Company GSID` and @@ -481,24 +520,32 @@ export function addNearMisses(map, candidates, fieldTerms) { // when no conventions section exists — the same false-provenance class F-307 // closed for the inactive states, on both deps surfaces). export function aliasMatchingLine(aliasRes, hasFieldTerms) { - const { source, origin, note, unsetWhy } = aliasRes ?? {}; + const { source, origin, note, unsetWhy, home } = aliasRes ?? {}; + // The convention's HOME rides its provenance (F-450 a): the workspace + // default is shared by every tenant, the tenant file is not. + const conventionHome = home === "tenant" ? "the tenant's own CONVENTIONS.md override" : "the workspace CONVENTIONS.md, shared by every tenant"; if (source && hasFieldTerms) return ( `case-insensitive exact match on system name or label, with task-alias prefix stripping ` + - `(\`${source}\`, from ${origin === "flag" ? "the explicit `--alias-prefix` flag" : "the tenant conventions"}) ` + + `(\`${source}\`, from ${origin === "flag" ? "the explicit `--alias-prefix` flag" : `the tenant conventions — ${conventionHome}`}) ` + `and space↔underscore separator equivalence — never substring` ); const base = "case-insensitive exact match on system name or label"; if (!hasFieldTerms) return base; if (note) return `${base} (declared field-aliasing convention MALFORMED — aliasing withheld; see Caveats)`; - return `${base} (field aliasing not in force: ${unsetWhy ?? "no field-aliasing convention supplied"})`; + // The unset arm names the home it read when one was read (F-450 a, review + // round): an existing tenant file that declares nothing SHADOWS a workspace + // declaration, and a line that only says "not in force" sent the operator to + // edit the wrong file. + const readFrom = home ? `, read from ${conventionHome}` : ""; + return `${base} (field aliasing not in force: ${unsetWhy ?? "no field-aliasing convention supplied"}${readFrom})`; } // The ER-21/ER-23 honesty caveats, worded ONCE for both deps surfaces: // without an aliasing convention the exact-only matcher is blind to // task-alias-prefixed fields (never silently); with one, near-misses are // shown to the human but never counted. nearMisses: [{term, names[]}]. -export function aliasFieldCaveats({ hasFieldTerms, aliasActive, nearMisses = [], conventionNote = null, unsetWhy = null }) { +export function aliasFieldCaveats({ hasFieldTerms, aliasActive, nearMisses = [], conventionNote = null, unsetWhy = null, unsetHome = null, unsetPath = null }) { const caveats = []; // A MALFORMED convention already states "ran EXACT-ONLY" with its why — // the generic exact-only caveat would restate it in different words @@ -510,7 +557,12 @@ export function aliasFieldCaveats({ hasFieldTerms, aliasActive, nearMisses = [], `(${unsetWhy ?? "none declared"}), so fields ` + `carrying a task-alias prefix (a tenant build standard can prefix every task-built field, e.g. \`X_\`) ` + `did NOT match their unprefixed names and real dependents may be missing from this report. If the tenant ` + - `aliases fields, declare the pattern in the workspace CONVENTIONS.md \`## Field aliasing\` section — this ` + + (unsetHome === "tenant" + ? `aliases fields, declare the pattern in the \`## Field aliasing\` section of the tenant's own ${unsetPath ?? "/CONVENTIONS.md"} — ` + + `that file exists and OVERRIDES the workspace CONVENTIONS.md for this tenant (a workspace declaration is not read while it exists; ` + + `delete the tenant file to fall back) — this ` + : `aliases fields, declare the pattern in the workspace CONVENTIONS.md \`## Field aliasing\` section (or in a \`/CONVENTIONS.md\` ` + + `override when only this tenant aliases — the report reads that file first) — this `) + `report reads it itself — or re-run with \`--alias-prefix ''\`.` ); for (const nm of nearMisses) { @@ -1479,6 +1531,8 @@ function buildCaveats(index, opts, result, provenance) { nearMisses: result.nearMisses ?? [], conventionNote: opts.aliasConventionNote ?? null, unsetWhy: opts.aliasUnsetWhy ?? null, + unsetHome: opts.aliasUnsetHome ?? null, + unsetPath: opts.aliasUnsetPath ?? null, }) ); if (opts.fieldTerms.length && !opts.tokens) @@ -1688,6 +1742,8 @@ export async function run(argv) { opts.aliasPrefix = aliasPrefix; opts.aliasConventionNote = aliasRes.note; opts.aliasUnsetWhy = aliasRes.unsetWhy; + opts.aliasUnsetHome = aliasRes.home ?? null; + opts.aliasUnsetPath = aliasRes.path ?? null; if (aliasRes.note) warnings.push(aliasRes.note); if (aliasPrefix && !fieldTerms.length) warnings.push("--alias-prefix has no effect without --field terms (aliasing applies to field matching only)"); diff --git a/plugins/gs-superadmin/scripts/manifest.mjs b/plugins/gs-superadmin/scripts/manifest.mjs index e90ef3c..c895752 100644 --- a/plugins/gs-superadmin/scripts/manifest.mjs +++ b/plugins/gs-superadmin/scripts/manifest.mjs @@ -374,7 +374,9 @@ * @property {string} [recheckAfter] YYYY-MM-DD * * ── report's output (T-2 v3, Session C1 2026-09-15 — ADDITIVE over the v2 - * shape; every key below is frozen once a skill quotes it). Readers: the + * shape; v4, Session C2 2026-09-15, ADDITIVE again — `scope` on every domain + * row and `pinFacts` at the top, F-450 c; every key below is frozen once a + * skill quotes it). Readers: the * setup skill (Phase 4 relay, Phase 5 close, Phase 6 precondition, --deep * relay), the refresh skill (step 1 / step 4), describe-batch's progress * probe (byDomain ONLY — it sums a row's values, so a byDomain row carries @@ -409,6 +411,20 @@ * depth) with no doc_path — the set * reconcile-docs can record for, so its * `recorded` never exceeds this (F-459 reopen) + * @property {?{key: string, path: string, limit: string}} scope (v4, F-450 c) + * the LIST COMMAND's scope limit at the pinned + * CLI — a per-CLI fact read from doc-lib's + * CLI_PIN_FACTS through the stamp's recorded + * listCommand, never recorded per tenant: key = + * the catalog command id, path = its canonical + * path (the `###` subsection of setup's + * index-scope-notes.md that carries the canon), + * limit = the one-line paraphrase. null = no + * limit known for the command at this pin, or + * nothing resolvable (no recording, no + * catalog, an unresolvable line) — read + * `pinFacts.applied` before reading null as + * "no limit" * * @typedef {object} GsReport * @property {true} ok @@ -433,6 +449,13 @@ * an unstamped domain holds entries (v3) * @property {number} docPathsUnknown tenant total (v3) * @property {Object} domains keyed by domain name (v3) + * @property {{stamped: string, catalogVersion: ?string, applied: boolean, why: ?string}} pinFacts + * (v4) whether doc-lib's per-pin CLI facts were + * applied to this report: stamped = the table's + * version, catalogVersion = the catalog's (null + * when none was found), applied = equal; why + * names the mismatch, null when applied. When + * false every `scope` is null by construction */ import { writeFileSync, mkdirSync, existsSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; @@ -440,7 +463,7 @@ import { fileURLToPath } from "node:url"; // Doc filename for an asset id — the shared copy every doc writer imports // (sanitized ids get a short raw-id hash so distinct ids that clean to the // same base can never collide). -import { docNameClaimer, docNameMatcher, docPathFor, docDirNorm, readJsonFile, writeFileAtomicSync, cmpKey, getPath as get, findItemsArray, extractIds, makeCliHelpers, STUB_MARKER, DESCRIBE_NONE, idPathHint, ZERO_RESOLVE_HEAD, findWorkspaceCatalog, makeCommandResolver } from "./doc-lib.mjs"; +import { docNameClaimer, docNameMatcher, docPathFor, docDirNorm, readJsonFile, writeFileAtomicSync, cmpKey, getPath as get, findItemsArray, extractIds, makeCliHelpers, STUB_MARKER, DESCRIBE_NONE, idPathHint, ZERO_RESOLVE_HEAD, findWorkspaceCatalog, makeCommandResolver, pinFactsFor } from "./doc-lib.mjs"; const here = dirname(fileURLToPath(import.meta.url)); // The date-failure head refresh/SKILL.md's post-upgrade migration note quotes @@ -658,10 +681,20 @@ function readKeysFile(path) { return arr; } +// A BLANK string is a present date path carrying NO value (F-450): `cn chains` +// at CLI 1.0.9 emits modifiedDateStr as "" on every row. ONE predicate for every +// reader of a date value — the upsert row loop, newerThan, the baseline branch +// and report's datelessEntries all consult it, so no reader has to remember the +// third state (review round: the first cut treated "" as null in the row loop +// only, and a stored blank flipped stale under adoption while report counted +// it as dated). +const isBlankDate = (v) => typeof v === "string" && v.trim() === ""; +const isDateless = (v) => v == null || isBlankDate(v); + // stale iff the live date is newer than the recorded one (tolerates non-ISO strings) function newerThan(live, recorded) { - if (live == null) return false; - if (recorded == null) return true; + if (isDateless(live)) return false; + if (isDateless(recorded)) return true; // Epoch-ms values (numbers or numeric strings) first: Date.parse of a numeric // string is NaN, which used to demote them to string inequality — change was // detected but ordering wasn't, so an out-of-order OLDER date flipped stale. @@ -1064,7 +1097,7 @@ if (verb === "init") { return Date.parse(v); }; /** rows whose --name-field PATH exists (F-421: a field absent on every row is an operator error, not "no name") */ - let namePresent = 0, namesBackfilled = 0; + let namePresent = 0, namesBackfilled = 0, blankDatesCleared = 0, dateBlank = 0, frozenDated = 0; /** * The first few top-level keys of a row — the one hint shape every "this * field resolves to nothing" message derives from the offending rows @@ -1106,11 +1139,25 @@ if (verb === "init") { // item has no date", never "this domain has no date field"); only an // ABSENT path is the dead-path signature the refusal below is for. const modifiedRaw = effDateField ? get(it, effDateField) : undefined; - const modified = modifiedRaw ?? null; + // A BLANK string is a present path carrying NO value (F-450, the + // connectors-chains disagreement: `cn chains` at CLI 1.0.9 emits + // modifiedDateStr as "" on every row — one tenant recorded the field and + // stored "" as a date, the other recorded none; newerThan("", "") is + // false, so the stored blank could never flag a change while report + // counted the row as dated). Present-but-empty is data like null: + // datePresent counts the path, dateResolved does not. + const blankDate = isBlankDate(modifiedRaw); + const modified = isDateless(modifiedRaw) ? null : modifiedRaw; + if (blankDate) dateBlank++; if (modifiedRaw !== undefined) datePresent++; if (modified != null) { dateResolved++; resolvedDates.push(modified); } if (nameField && get(it, nameField) !== undefined) namePresent++; const prev = m.inventory[key]; + // A blank stored before the blank rule is cleared to null BEFORE the branch + // chain, so the baseline branch below sees a null (never a stale flip on + // adoption) and report's datelessEntries counts the row — a repair, not a + // change; status untouched, counted separately. + if (prev && isBlankDate(prev.modified_date)) { prev.modified_date = null; blankDatesCleared++; } if (!prev) { m.inventory[key] = { id: String(id), name, domain, modified_date: modified, status: "pending" }; added++; @@ -1130,6 +1177,11 @@ if (verb === "init") { // never wrote). A null stored name filled from a non-null incoming one // is a repair, not a change — status untouched, counted separately. if (prev.name == null && name != null) { prev.name = name; namesBackfilled++; } + // An incoming dateless row over a STORED real date keeps the stored + // date (never erased) — but nothing can ever flag that entry again + // while the list stays dateless: counted, so the all-blank warning can + // say so instead of claiming every entry is dateless. + if (modified == null && prev.modified_date != null) frozenDated++; unchanged++; } } @@ -1382,11 +1434,28 @@ if (verb === "init") { `entries — probable under-pagination (CLI list defaults return 20–50 items with no truncation ` + `warning). Re-page and re-upsert before inferring any deletion; expected and ignorable only ` + `for a single page of a multi-page fetch, a recency-filtered list, or a SCOPE-LIMITED domain — ` + - `a list command that cannot see the whole tenant (setup's references/index-scope-notes.md names ` + - `them; \`jo email templates\` is one), where the shortfall is permanent and re-paging cannot ` + + `a list command that cannot see the whole tenant (report's domains..scope names the limit from the ` + + `per-pin table; setup's references/index-scope-notes.md is the canon), where the shortfall is permanent and re-paging cannot ` + `close it. ${remedy}` ); } + // An EMPTY STRING on every present row (F-450): the blank-string shape + // above — not the dead-path signature (the key exists), and not the + // present-but-null shape either (F-392 ruled that data, warning-free: a + // null is "this item has no date"), but a field the command fills with + // "" for every row can never be compared. A warning, never a refusal — + // the recording is the operator's to change with --allow-redate. + if (!partial && typeof effDateField === "string" && idRows > 0 && dateBlank > 0 && dateBlank === datePresent && dateResolved === 0) { + warnings.push( + `--date-field ${effDateField}${dateFieldSource.startsWith("recorded") ? ` (the recording for domain ${domain})` : ""} is present on ` + + `${datePresent} row(s) but is an empty string on every one — the incoming rows carry no date to compare` + + (frozenDated + ? `; ${frozenDated} entry(ies) keep a date stored by an earlier list and read as dated in report while nothing can flag them again — change detection for this domain is FROZEN` + : `; every matched entry is stored dateless (report's datelessEntries names them)`) + + `. If the command never emits a modified date ('cn chains' at CLI 1.0.9 emits modifiedDateStr as an empty string), ` + + `record --no-date-field --allow-redate once.` + ); + } // Mass-stale advisory: a large fraction flipping stale at once is the // signature of a wrong date field (or a platform event bumping dates en // masse), not of that many real edits. @@ -1400,7 +1469,7 @@ if (verb === "init") { } const result = { ok: true, domain, added, stale, unchanged, skipped, baselined, matchedExisting, - dateField: effDateField, dateFieldSource, datePresentRows: datePresent, dateResolvedRows: dateResolved, namePresentRows: namePresent, namesBackfilled, incomingCount, existingEntries, partial, warnings, + dateField: effDateField, dateFieldSource, datePresentRows: datePresent, dateResolvedRows: dateResolved, namePresentRows: namePresent, namesBackfilled, blankDatesCleared, incomingCount, existingEntries, partial, warnings, totalInventory: Object.keys(m.inventory).length, }; if (orphaned) { @@ -1702,12 +1771,12 @@ if (verb === "init") { // recorded-none, ABSENT = legacy/unknown — a bare-string stamp or no // stamp at all reads as the third state, never as "none". const changeDetection = isObj && "dateField" in stamp ? (typeof stamp.dateField === "string" ? "date" : "none") : "unrecorded"; - domains[d] = { stamped, describeState: describeStateOf(stamp), byDepth: newDepth(), changeDetection, datelessEntries: 0, docPathsUnknown: 0 }; + domains[d] = { stamped, describeState: describeStateOf(stamp), byDepth: newDepth(), changeDetection, datelessEntries: 0, docPathsUnknown: 0, scope: null }; } let docPathsUnknown = 0; for (const e of entries) { const row = domains[e.domain]; - if (e.modified_date == null) row.datelessEntries++; + if (isDateless(e.modified_date)) row.datelessEntries++; // a stored blank (pre-F-450) is dateless too if (everDocumented(e) && !hasDocPath(e)) { row.docPathsUnknown++; docPathsUnknown++; } if (e.status !== "documented") continue; // A metadata stub's completeness is its DOMAIN's recorded describe state @@ -1725,6 +1794,25 @@ if (verb === "init") { byDepth[bucket]++; } const domainCounts = { indexed: Object.keys(domainsIndexed).length, withAssets: Object.keys(byDomain).length, empty: emptyDomains.length }; + // Scope limits (F-450 c / F-452): a per-CLI fact of the LIST COMMAND, read + // from doc-lib's CLI_PIN_FACTS through each stamp's recorded listCommand + // resolved against the catalog (the same resolver upsert-batch's fork + // guard uses) — never recorded per tenant, so there is nothing to + // backfill and the relay can name every limit the day the table ships. + // null = no limit known for the command at this pin, OR nothing could be + // resolved (no catalog, no recording, an unresolvable line); `pinFacts` + // at the top level says whether the table applied at all, so a pin move + // can never read as "no limits" (A-4). + const catalog = findWorkspaceCatalog(dirname(resolve(manifestPath)), join(here, "..", "reference", "catalog.json")); + const pin = pinFactsFor(catalog); + const resolveLine = catalog ? makeCommandResolver(catalog).resolveLine : null; + for (const d of domainNames) { + const stamp = domainsIndexed[d]; + const lc = stamp != null && typeof stamp === "object" && typeof stamp.listCommand === "string" ? stamp.listCommand : null; + const cmd = lc && resolveLine ? resolveLine(lc) : null; + const fact = cmd && typeof cmd.id === "string" && Object.hasOwn(pin.commands, cmd.id) ? pin.commands[cmd.id] : null; + if (fact && typeof fact.scope === "string") domains[d].scope = { key: cmd.id, path: cmd.path, limit: fact.scope }; + } // Per-domain change window (F-417): days since the domain's OWN list stamp // — the `at` upsert-batch writes on every full list — never since a // workspace-wide last_refresh. A crawl that reached 2 of 17 domains leaves @@ -1761,6 +1849,7 @@ if (verb === "init") { domainCounts, docPathsUnknown, domains, + pinFacts: { stamped: pin.stamped, catalogVersion: pin.catalogVersion, applied: pin.applied, why: pin.why }, }); } else if (verb === "exclude" || verb === "block") { const m = load(); diff --git a/plugins/gs-superadmin/scripts/tenant-deps.mjs b/plugins/gs-superadmin/scripts/tenant-deps.mjs index 8f9d99d..e49515a 100644 --- a/plugins/gs-superadmin/scripts/tenant-deps.mjs +++ b/plugins/gs-superadmin/scripts/tenant-deps.mjs @@ -36,7 +36,8 @@ // in every output row (it names the originating task); near-misses are // listed in a not-counted caveat. The convention is read by the SCRIPT // itself (GP-B5 DS-27: jo-report-deps readAliasConvention, from the -// workspace's .gs-superadmin/CONVENTIONS.md, walking up from --kb) — +// workspace's .gs-superadmin/CONVENTIONS.md, walking up from --kb, or the +// tenant's own /CONVENTIONS.md override when one exists, F-450 a) — // --alias-prefix is the explicit override. Missing/unset → exact-only plus // a conventions-unavailable caveat; malformed → exact-only plus a loud // caveat naming why. A pattern is never inferred from tenant data (A-4). @@ -1859,6 +1860,8 @@ export async function run(argv) { nearMisses: [...nearMissByTerm.entries()].map(([term, names]) => ({ term, names: [...names].sort() })), conventionNote: aliasRes.note, unsetWhy: aliasRes.unsetWhy, + unsetHome: aliasRes.home ?? null, + unsetPath: aliasRes.path ?? null, }) ); if (terms.objectTerms.length) diff --git a/plugins/gs-superadmin/skills/audit/SKILL.md b/plugins/gs-superadmin/skills/audit/SKILL.md index 3a3da90..8f8cf07 100644 --- a/plugins/gs-superadmin/skills/audit/SKILL.md +++ b/plugins/gs-superadmin/skills/audit/SKILL.md @@ -30,10 +30,13 @@ Same as `/gs-superadmin:refresh` step 1: run `gs-admin whoami`, derive the slug ### 2 — Load the naming convention -Read, in order of preference: -1. `.gs-superadmin/conventions/naming.md` (adopted build-standards pack) -2. The **Naming** section of `.gs-superadmin/CONVENTIONS.md` — but only if it has been - filled in; ignore content still marked as placeholder/example formats. +Read, in order of preference (the tenant file wins over the workspace-wide pack — +operating model, "Build standards"): +1. The **Naming** section of the tenant's `/CONVENTIONS.md` when that file exists + and the section has been filled in (ignore content still marked as placeholder/example + formats). +2. `.gs-superadmin/conventions/naming.md` (adopted build-standards pack). +3. The **Naming** section of `.gs-superadmin/CONVENTIONS.md` — again only if filled in. If neither defines a real convention, tell the user: "No naming convention is defined for this workspace — edit `.gs-superadmin/CONVENTIONS.md` (or re-run `/gs-superadmin:setup` and diff --git a/plugins/gs-superadmin/skills/change-request/SKILL.md b/plugins/gs-superadmin/skills/change-request/SKILL.md index 35ef9d2..d5352aa 100644 --- a/plugins/gs-superadmin/skills/change-request/SKILL.md +++ b/plugins/gs-superadmin/skills/change-request/SKILL.md @@ -157,8 +157,10 @@ Impact questions to answer explicitly: ### 5 — Names, conventions, risk **Names**: load the workspace convention exactly as `/gs-superadmin:audit` step 2 does -(`.gs-superadmin/conventions/naming.md`, else a filled-in Naming section of -`.gs-superadmin/CONVENTIONS.md`). Compose convention-compliant names for every asset the +(a filled-in Naming section of the tenant's `/CONVENTIONS.md` when it exists, else +`.gs-superadmin/conventions/naming.md`, else a filled-in Naming section of +`.gs-superadmin/CONVENTIONS.md` — operating model, "Build standards"). Compose +convention-compliant names for every asset the plan creates or renames. If no convention is adopted, propose descriptive names and flag them `(no workspace naming convention — unchecked)` rather than inventing a convention. Apply the build-standards pack where adopted (`.gs-superadmin/conventions/rules-engine.md` diff --git a/plugins/gs-superadmin/skills/deprecate/SKILL.md b/plugins/gs-superadmin/skills/deprecate/SKILL.md index a609f0a..5160aaf 100644 --- a/plugins/gs-superadmin/skills/deprecate/SKILL.md +++ b/plugins/gs-superadmin/skills/deprecate/SKILL.md @@ -6,9 +6,11 @@ argument-hint: " [--domain rules|data-designer|reports] [--ticket RE # /gs-superadmin:deprecate -Deprecate an asset per this workspace's deprecation process (default source: -`.gs-superadmin/conventions/deprecation.md`; fall back to any deprecation guidance in -`.gs-superadmin/CONVENTIONS.md`). If neither defines a process, tell the user and stop — +Deprecate an asset per this workspace's deprecation process (in order: deprecation +guidance in the tenant's `/CONVENTIONS.md` when that file exists, else +`.gs-superadmin/conventions/deprecation.md`, else `.gs-superadmin/CONVENTIONS.md` — +the precedence is the operating model's "Build standards" rule). If none defines a +process, tell the user and stop — never invent a deprecation convention. ## Arguments diff --git a/plugins/gs-superadmin/skills/deps-report/SKILL.md b/plugins/gs-superadmin/skills/deps-report/SKILL.md index 0008312..4b43c3e 100644 --- a/plugins/gs-superadmin/skills/deps-report/SKILL.md +++ b/plugins/gs-superadmin/skills/deps-report/SKILL.md @@ -57,8 +57,10 @@ separators — still exact, never substring; the report header names the pattern and the full prefixed names stay in the output. - `--alias-prefix ` — an explicit override only: the report script reads the - tenant's field-aliasing convention from the workspace's `.gs-superadmin/CONVENTIONS.md` - itself (missing or malformed → exact-only matching with a caveat in the report — it + tenant's field-aliasing convention itself — from the tenant's own `/CONVENTIONS.md` + when that file exists, else from the workspace's `.gs-superadmin/CONVENTIONS.md`; the + report names which (operating model, "Build standards") — (missing or malformed → + exact-only matching with a caveat in the report — it never infers a pattern from tenant data; an empty value `''` disables aliasing outright). Carry the report's conventions caveat into step 5. diff --git a/plugins/gs-superadmin/skills/email-report/SKILL.md b/plugins/gs-superadmin/skills/email-report/SKILL.md index 9fe28cf..015f8b0 100644 --- a/plugins/gs-superadmin/skills/email-report/SKILL.md +++ b/plugins/gs-superadmin/skills/email-report/SKILL.md @@ -218,8 +218,10 @@ Skip only if nothing was fetched. ### 5 — Run the mode **deps mode — field aliasing.** The report script reads the tenant's -field-aliasing convention from the workspace's `.gs-superadmin/CONVENTIONS.md` -itself (missing or malformed → exact-only matching with a caveat in the report +field-aliasing convention itself — from the tenant's own `/CONVENTIONS.md` when +that file exists, else from the workspace's `.gs-superadmin/CONVENTIONS.md`, and names +which (operating model, "Build standards") — (missing or malformed → exact-only +matching with a caveat in the report — it never infers a pattern from tenant data; an empty `--alias-prefix ''` disables aliasing outright); pass `--alias-prefix ''` only when the user supplied an explicit override, and carry the report's diff --git a/plugins/gs-superadmin/skills/refresh/SKILL.md b/plugins/gs-superadmin/skills/refresh/SKILL.md index b495da4..c16e3d8 100644 --- a/plugins/gs-superadmin/skills/refresh/SKILL.md +++ b/plugins/gs-superadmin/skills/refresh/SKILL.md @@ -205,10 +205,12 @@ compare the total you fetched against `report`'s `byDomain` count, and read the `incomingCount` / `existingEntries` / `warnings`: it warns when the incoming list is smaller than the domain's non-failed inventory (expected and ignorable only for a single page of a multi-page fetch, a recency-filtered list, or a **scope-limited domain** — a -list command that cannot see the whole tenant, named in setup's -`references/index-scope-notes.md`; `jo email templates` is one, and its shortfall is -permanent: re-paging cannot close it, and the same warning returns on every refresh — -read it as the known scope limit, not as under-fetch). **The default reading of any +list command that cannot see the whole tenant: step 1's report names every such domain +on its `domains..scope` row (`limit` says what the CLI hides; the canon is +setup's `references/index-scope-notes.md`, one subsection per command), and its +shortfall is permanent: re-paging cannot close it, and the same warning returns on every +refresh — read it as the known scope limit, not as under-fetch; a domain whose `scope` +is null gets no such reading). **The default reading of any other shortfall is "probable under-pagination → re-page and re-upsert"** — `upsert-batch` never removes entries. Never pass `--partial` on a refresh re-list: that flag declares a deliberate-subset registration (gap-fill flows), and here it would silence exactly diff --git a/plugins/gs-superadmin/skills/setup/SKILL.md b/plugins/gs-superadmin/skills/setup/SKILL.md index c1a8cf3..079bb41 100644 --- a/plugins/gs-superadmin/skills/setup/SKILL.md +++ b/plugins/gs-superadmin/skills/setup/SKILL.md @@ -239,12 +239,14 @@ script cannot make: exists) — never presented as complete. - Reconciliation proves **paging** completeness against the payload's own post-filter total — never inventory completeness (scope-limited domains, next bullet). -- **Scope-limited domains — paging cannot fix these.** Several journey-side list - commands cannot see the whole tenant (`jo email templates`, `jo surveys list`, - `jo data-designer list`; Data Designer *designs* have no list command at all, and - are not identifiable among dm objects). Before indexing any of those — or reasoning - about their coverage, or recovering list-invisible templates from user-supplied - ids — read `references/index-scope-notes.md`: the per-command limits, the +- **Scope-limited domains — paging cannot fix these.** Some list commands cannot see + the whole tenant; which ones, at the pinned CLI, is shipped data (doc-lib's per-pin + table), and `report` names each such domain's limit on its `domains..scope` + row — never a list carried from memory. (Data Designer *designs* have no list command + at all, and are not identifiable among dm objects.) Before indexing a journey-side + domain — or reasoning about its coverage, or recovering list-invisible templates from + user-supplied ids — read `references/index-scope-notes.md`: the per-command limits + (one subsection per limited command, the canon a `scope.path` points at), the "CLI-reachable subset" annotation and UI-vs-CLI gap rules, and the recovery flow. Consult `.gs-superadmin/cheatsheet.md` for the exact commands. Skip `query` (no list @@ -334,15 +336,24 @@ node .gs-superadmin/plugin/scripts/domain-candidates.mjs diff --manifest / The script derives every candidate mechanically from the workspace catalog (artifact-lane, non-mutating, tenant-wide list commands — never from recall) and maps each against the manifest: **indexed** (a `domains_indexed` entry's recorded `listCommand` matches), -**excluded** (recorded in `domains_excluded`), or **undecided**. Surface the undecided -candidates to the user **by name** — the decisions are tenant policy, not bookkeeping — -then work through them in the order printed. A candidate flagged `likelyPerAsset: true` -is probably a per-asset sublist whose required flag the catalog does not declare (the -filter drops only *declared* required flags): expect its tenant-wide run to fail with a -runtime "X is required" error, and when it does, exclude it as a per-asset sublist -citing that error (no capture exists, so no overlap check can run: pass -`--no-check ""` in place of `--check`, step 3) — the flag is -advisory, so still run it once rather than excluding on the hint alone. A candidate carrying a non-null `requiredEnumFlags` is runnable +**excluded** (recorded in `domains_excluded`), or **undecided** — after setting aside +`notEnumerableBare`: the commands the pinned CLI is known to refuse without a +per-asset flag its catalog never declares (the plugin ships that fact, version-stamped; +each entry names the flag it needs and the runtime error). Those are NOT work: no +decision is recorded for them, they never gate, and a tenant exclusion or block found +against one is reported inert (`tenantRecord`) and not counted — lift it or leave it. +Surface the undecided candidates to the user **by name** — the decisions are tenant +policy, not bookkeeping — then work through them in the order printed. A candidate +flagged `likelyPerAsset: true` outside that list is probably a per-asset sublist whose +required flag the catalog does not declare (the filter drops only *declared* required +flags): expect its tenant-wide run to fail with a runtime "X is required" error, and +when it does, exclude it as a per-asset sublist citing that error (no capture exists, +so no overlap check can run: pass `--no-check ""` in place of +`--check`, step 3) — the flag is advisory, so still run it once rather than excluding +on the hint alone, and report the command so the shipped list can carry it at the next +pin. If the diff warns that the shipped list was not applied (`pinFacts.applied` false — +the workspace catalog is at another CLI version), relay the warning: every sublist is +then ordinary undecided work again until the plugin is updated. A candidate carrying a non-null `requiredEnumFlags` is runnable tenant-wide only through those enum flags (e.g. `--type` over `MDA|SFDC`): to evaluate or adopt it, run the list once per enum value and combine the captures — a decision on such a candidate covers every value, and its reason should say so. @@ -427,7 +438,8 @@ such a candidate covers every value, and its reason should say so. they pass `--no-check ""` in place of `--check`, where `` is the cause and nothing else: the payload carries no items array (as `journey cta options`), or the list command failed at runtime with a required-flag - error (step 1's per-asset sublists). Add `--recheck-after ` when the + error (a step-1 per-asset sublist the shipped `notEnumerableBare` list does not already + name). Add `--recheck-after ` when the reason rests on a tenant state that can change (a 0-row list, "empty on this tenant") rather than on the payload's shape — the diff surfaces the exclusion by name once the date passes, as it does a block's. @@ -464,8 +476,23 @@ entries with no coverage stamp (a `--partial` registration into a never-listed d find it as the `domains` row with `stamped: false` and name it separately ("domain: count — registered, never listed; no coverage stamp"), never folded into W. Name each of the report's `emptyDomains` explicitly ("domain: 0 — listed, -tenant has none") and the exclusion ledger ("K list commands deliberately excluded — -reasons recorded in `domains_excluded`"). Treat any domain whose count lands exactly on a common default +tenant has none"), the exclusion ledger ("K list commands deliberately excluded — +reasons recorded in `domains_excluded`", K = the diff's `excludedCount`) and, separately, +the diff's `notEnumerableBareCount` ("N list commands the CLI cannot run bare — no +decision needed"). Then, **before** asking about totals, name every scope-limited domain +— each `domains` row of the report whose `scope` is not null — with its limit, so the +question is answerable (F-452: a CLI-reachable subset otherwise reads as a tenant +total): one line per such row, +`: — CLI-reachable subset: ; the remainder can be added later (references/index-scope-notes.md § )`, +where `` is the row's key in `domains`, `` and `` are +quoted from that row verbatim (the reference's subsection headed by `` is +the canon — never restate a limit from memory), `` is the domain's `byDomain` +total — `0` for a row that has no `byDomain` entry because it is in `emptyDomains`, +whose scope line then REPLACES its "listed, tenant has none" line (one line per domain, +never two) — and a row whose `scope` is null gets no line. If the report's +`pinFacts.applied` is false, write "scope limits not evaluated: " in place +of the lines, `` quoted from the report verbatim — never "none". +Treat any domain whose count lands exactly on a common default page size (20, 25, 50) as **suspect** — re-verify its pagination was exhausted before continuing — and ask the user whether the totals match their sense of the tenant before Phase 5 spends the documentation budget. diff --git a/plugins/gs-superadmin/skills/setup/references/index-scope-notes.md b/plugins/gs-superadmin/skills/setup/references/index-scope-notes.md index 42486bb..4666681 100644 --- a/plugins/gs-superadmin/skills/setup/references/index-scope-notes.md +++ b/plugins/gs-superadmin/skills/setup/references/index-scope-notes.md @@ -26,24 +26,55 @@ domain's addressing flag differs from `--id`, the recorded template carries the real flag (`gs-admin --json dm objects describe --name {id}`, `jo dd get --name`). No `gs-admin` command accepts a bare positional argument. -## Scope-limited domains (1.0.4) — paging cannot fix these - -Some list commands cannot see the whole tenant: `jo email templates` flattens only -one level of the folder tree (templates in nested subfolders are dropped — validated -live; subfolder nodes appear as field-less rows carrying only `folderName` — skip -them, they have no id) and additionally hides some top-level templates (residual -`source=COMMS`/state filter, cause unconfirmed); `jo surveys list` hardcodes -`states:["PUBLISH"]` (closed surveys invisible); `jo data-designer list` pins -`ds=UNIVERSAL_DATA_SET`; Data Designer *designs* have no list command — and their -output objects hide among `dm` objects where `group=System` is a **superset** (some -System objects are DD outputs, some are not; no 1.0.4 describe field distinguishes -them), so "which dm objects are Data Designers" is not determinable from CLI data — -never equate `group=System` with "the data designers" (`dataStore=REDSHIFT` and -`copy`/`view` `dbName` prefixes are weak hints only). Totals are -post-filter/post-flatten. Index what the CLI returns, annotate these domains as -"CLI-reachable subset" in the relay, and if the user reports a higher UI count, -record the gap (UI vs CLI-reachable, date, suspected cause) in `/overview.md` -as a known limitation — do not chase it. +## Scope-limited domains — paging cannot fix these + +Some list commands cannot see the whole tenant: their handlers hardcode a filter or +flatten a tree, so the count they yield is a CLI-reachable SUBSET, post-filter and +post-flatten. These are facts about the pinned CLI, not about a tenant, so no tenant +records them: the plugin ships them once, as data, in doc-lib's `CLI_PIN_FACTS` (keyed +by the catalog command id, version-stamped, self-retiring at the next pin), and +`manifest.mjs report` derives `domains..scope` — `{ key, path, limit }` — for +every domain whose recorded `listCommand` resolves to one of them (F-450; `null` when no +limit is known at the pin, and the report's `pinFacts.applied` says whether the table +applied at all). The subsections below are the canon, one per limited command, each +headed by the command's canonical path — a report row's `scope.path` names its +subsection — and `build/check-doc-drift.mjs` holds these headings and that table to +each other both ways. Index what the CLI returns; the Phase 4 relay names every +scope-limited domain from `scope`, with its limit and the fact that the remainder can be +added later (the recovery flow in the last section); and if the user reports a higher +UI count, record the gap (UI vs CLI-reachable, date, suspected cause) in +`/overview.md` as a known limitation — do not chase it. + +### journey email templates + +Flattens only one level of the folder tree — templates in nested subfolders are +dropped (validated live on 1.0.4; subfolder nodes appear as field-less rows carrying +only `folderName` — skip them, they have no id) — and additionally hides some +top-level templates (residual `source=COMMS`/state filter, cause unconfirmed). The +command fetches its whole already-scope-filtered result server-side and caps +client-side at 50 by default, so pass a large `--limit` (Phase 4's paging bullets). +Templates the list cannot see are recoverable by id — the last section. + +### journey surveys list + +Hardcodes `states:["PUBLISH"]`: closed surveys are invisible. The command has no +paging flags — one fetch is the whole reachable set. + +### journey data-designer list + +Pins `ds=UNIVERSAL_DATA_SET`, one dataset type, with no paging flags. Its rows are +Data Designer OUTPUT datasets keyed by `objectName`, every one of them also present in +`data-management` (measured on a sandbox KB, 2026-09-11, Session A) — so the list is a +MEMBERSHIP SIGNAL over `data-management` for the status it filters on, not a domain of +its own: decide it as a coverage exclusion over `data-management` through the +evidence-bound verb (Phase 4 step 3), never as a separate domain (one workspace carried +a `journey-data-designer` domain for exactly this and it was removed). Data Designer +*designs* have no list command, and their output objects hide among `dm` objects where +`group=System` is a **superset** (some System objects are DD outputs, some are not; no +1.0.4 describe field distinguishes them): beyond what this list returns, "which dm +objects are Data Designers" is not determinable from CLI data — never equate +`group=System` with "the data designers" (`dataStore=REDSHIFT` and `copy`/`view` +`dbName` prefixes are weak hints only). ## Recovering list-invisible email templates diff --git a/plugins/gs-superadmin/templates/CONVENTIONS.md b/plugins/gs-superadmin/templates/CONVENTIONS.md index 1b48d9a..ece249f 100644 --- a/plugins/gs-superadmin/templates/CONVENTIONS.md +++ b/plugins/gs-superadmin/templates/CONVENTIONS.md @@ -1,7 +1,10 @@ # Build Standards & Conventions -> **Tenant-specific** — fill in as patterns emerge. This file is yours to edit. -> It is loaded by Claude when authoring or modifying Gainsight assets in this workspace. +> **Workspace-wide by default** — fill in as patterns emerge. This file is yours to edit +> and applies to every tenant indexed in this workspace; a tenant that differs gets its own +> copy at `/CONVENTIONS.md`, which readers use for that tenant instead (operating +> model, "Build standards"). It is loaded by Claude when authoring or modifying Gainsight +> assets in this workspace. ## Standards library diff --git a/plugins/gs-superadmin/templates/operating-model.md b/plugins/gs-superadmin/templates/operating-model.md index 3dec34a..0cc86c4 100644 --- a/plugins/gs-superadmin/templates/operating-model.md +++ b/plugins/gs-superadmin/templates/operating-model.md @@ -437,7 +437,15 @@ When you discover new relationships (e.g., a rule writes to a field that a score ## Build standards Refer to `.gs-superadmin/CONVENTIONS.md` before authoring or modifying any Gainsight asset -(rules, journeys, reports, scorecards). If `.gs-superadmin/conventions/` exists (the adopted +(rules, journeys, reports, scorecards). That file is **workspace-wide**: every tenant +indexed here reads it, unless a `/CONVENTIONS.md` exists beside that tenant's KB +folder — then that file replaces it for that tenant alone (the exception, not the norm: +one company, one set of conventions; copy the workspace file into `/` and edit it +only when a tenant genuinely differs). Every reader, scripted or prose, looks for the +tenant file first and says which one it used; an adopted `.gs-superadmin/conventions/` +pack stays workspace-wide and sits BETWEEN the two — a tenant file's filled-in section +wins over the pack, the pack over the workspace file's section — and an existing tenant +file is never completed from the workspace file (two files never read as one). If `.gs-superadmin/conventions/` exists (the adopted build-standards pack), consult `conventions/index.md` and load **only** the topic file relevant to the current task — `naming.md` for names/renames/audits, `query-building.md` for any dataset work (building or editing a query in Rules, Data Designer, or JO Programs; diff --git a/plugins/gs-superadmin/test/contract-conformance.mjs b/plugins/gs-superadmin/test/contract-conformance.mjs index 212099f..4e846b4 100644 --- a/plugins/gs-superadmin/test/contract-conformance.mjs +++ b/plugins/gs-superadmin/test/contract-conformance.mjs @@ -210,20 +210,31 @@ try { const T2_REPORT_KEYS = [ "ok", "slug", "baseUrl", "environment", "total", "last_refresh", "byStatus", "byDomain", "lookback", "lookbackDefault", "domains_indexed", "domains_excluded", "domains_blocked", "emptyDomains", - "byDepth", "domainCounts", "docPathsUnknown", "domains", + "byDepth", "domainCounts", "docPathsUnknown", "domains", "pinFacts", ].sort().join(","); - check("T-2 v3: report carries exactly the typedef keys (v2 set + byDepth/domainCounts/docPathsUnknown/domains)", Object.keys(rep).sort().join(",") === T2_REPORT_KEYS, Object.keys(rep).sort()); + check("T-2 v4: report carries exactly the typedef keys (v2 set + byDepth/domainCounts/docPathsUnknown/domains + pinFacts)", Object.keys(rep).sort().join(",") === T2_REPORT_KEYS, Object.keys(rep).sort()); const T2_DEPTH_KEYS = "full,listOnly,metadata,unrecorded"; check("T-2 v3: byDepth is exactly {full, metadata, listOnly, unrecorded}, all numbers", Object.keys(rep.byDepth).sort().join(",") === T2_DEPTH_KEYS && Object.values(rep.byDepth).every((v) => typeof v === "number"), rep.byDepth); check("T-2 v3: domainCounts is exactly {indexed, withAssets, empty}", Object.keys(rep.domainCounts).sort().join(",") === "empty,indexed,withAssets", rep.domainCounts); check("T-2 v3: docPathsUnknown is a number", typeof rep.docPathsUnknown === "number", rep.docPathsUnknown); - const T2_DOMAIN_ROW_KEYS = "byDepth,changeDetection,datelessEntries,describeState,docPathsUnknown,stamped"; - check("T-2 v3: one domains row per stamped or populated domain, each exactly {stamped, describeState, byDepth, changeDetection, datelessEntries, docPathsUnknown}", + const T2_DOMAIN_ROW_KEYS = "byDepth,changeDetection,datelessEntries,describeState,docPathsUnknown,scope,stamped"; + check("T-2 v4: one domains row per stamped or populated domain, each exactly {stamped, describeState, byDepth, changeDetection, datelessEntries, docPathsUnknown, scope}", Object.keys(rep.domains).sort().join(",") === [...new Set([...Object.keys(rep.byDomain), ...Object.keys(rep.domains_indexed)])].sort().join(",") && Object.values(rep.domains).every((d) => Object.keys(d).sort().join(",") === T2_DOMAIN_ROW_KEYS && Object.keys(d.byDepth).sort().join(",") === T2_DEPTH_KEYS), rep.domains); check("T-2 v3: describeState ∈ describable|list-only|unrecorded and changeDetection ∈ date|none|unrecorded on every row", Object.values(rep.domains).every((d) => ["describable", "list-only", "unrecorded"].includes(d.describeState) && ["date", "none", "unrecorded"].includes(d.changeDetection) && typeof d.stamped === "boolean"), rep.domains); + // v4 (F-450 c): scope is DERIVED from the stamp's recorded listCommand + // through doc-lib's per-pin table — the templates stamp above recorded + // `jo email templates`, a scope-limited command at the pin; the rules + // stamp recorded nothing. pinFacts says the table applied (the scratch + // dir has no workspace catalog, so the bundled one — at the pin — is read). + check("T-2 v4: pinFacts is exactly {stamped, catalogVersion, applied, why} and applied against the bundled catalog", + Object.keys(rep.pinFacts ?? {}).sort().join(",") === "applied,catalogVersion,stamped,why" && rep.pinFacts.applied === true && rep.pinFacts.why === null && rep.pinFacts.stamped === rep.pinFacts.catalogVersion, rep.pinFacts); + check("T-2 v4: scope is {key, path, limit} on the scope-limited templates domain (key = catalog id, path = canonical path) and null on the others", + rep.domains["journey-email-templates"]?.scope?.key === "journey:email:templates" && rep.domains["journey-email-templates"].scope.path === "journey email templates" && typeof rep.domains["journey-email-templates"].scope.limit === "string" && rep.domains["journey-email-templates"].scope.limit.length > 0 && + Object.keys(rep.domains["journey-email-templates"].scope).sort().join(",") === "key,limit,path" && rep.domains["rules-engine-rules"]?.scope === null && rep.domains["legacy-domain"]?.scope === null, + Object.fromEntries(Object.entries(rep.domains).map(([k, d]) => [k, d.scope]))); check("T-2 v3: byDomain rows are numbers only — nothing new was nested into them", Object.values(rep.byDomain).every((row) => Object.values(row).every((v) => typeof v === "number")), rep.byDomain); check("T-2 v3: the fixture's recorded-none domain reads changeDetection none; the recorded-field domain reads date; the legacy string-stamp domain reads unrecorded", rep.domains["rules-engine-rules"]?.changeDetection === "none" && rep.domains["journey-email-templates"]?.changeDetection === "date" && rep.domains["legacy-domain"]?.changeDetection === "unrecorded", Object.fromEntries(Object.entries(rep.domains).map(([k, d]) => [k, d.changeDetection]))); diff --git a/plugins/gs-superadmin/test/domain-candidates.mjs b/plugins/gs-superadmin/test/domain-candidates.mjs index 9ad83c5..e1abcfe 100644 --- a/plugins/gs-superadmin/test/domain-candidates.mjs +++ b/plugins/gs-superadmin/test/domain-candidates.mjs @@ -25,7 +25,7 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; import { spawnSync } from "node:child_process"; -import { makeCommandResolver } from "../scripts/doc-lib.mjs"; +import { makeCommandResolver, CLI_PIN_FACTS } from "../scripts/doc-lib.mjs"; const SCRIPTS = join(dirname(fileURLToPath(import.meta.url)), "..", "scripts"); const CANDIDATES = join(SCRIPTS, "domain-candidates.mjs"); @@ -156,9 +156,115 @@ let r = manifest("upsert-batch", [ ]); checkThat("fixture: connectors upsert succeeds with nested id field", r.code === 0 && r.json?.added === 4, r); +// ── F-450 b: the per-pin CLI facts — not-enumerable-bare sublists ───────────── +// A second workspace whose catalog is AT the stamped pin and carries the four +// real `re rules` sublists (real ids; the catalog declares no required flag on +// any of them — the upstream declaration gap) plus the real `dd sources +// fields` (declares --type[enum], keeps its requiredEnumFlags path). The +// shipped table must bucket the four WITHOUT any tenant record, keep the gate +// off them, report a tenant record written against one as inert, and keep an +// indexed domain visible when the table contradicts it. +{ + const R2 = join(ROOT, "pin"); + mkdirSync(join(R2, ".gs-superadmin"), { recursive: true }); + mkdirSync(join(R2, "acme-pin"), { recursive: true }); + const real = (path, shortPath, id, actionKey, summary, extra = {}) => + cmd(path, shortPath, path.split(" ")[0], path.split(" ").pop(), actionKey, summary, { id, ...extra }); + const catalogPin = { + ...catalog, + meta: { cliVersion: CLI_PIN_FACTS.cliVersion }, + commands: [ + // the synthetic F-219 executions command (a made-up id) gives way to the + // real-id one below — one command per path, as in a real catalog + ...catalog.commands.filter((c) => c.path !== "rules-engine rules executions"), + real("rules-engine rules events", "re r events", "rules-engine:rules:events", "events", "List Events Framework events for a topic"), + real("rules-engine rules executions", "re r executions", "rules-engine:rules:executions", "list-rule-executions", "List execution history for a rule"), + real("rules-engine rules s3-tasks", "re r s3-tasks", "rules-engine:rules:s3-tasks", "s3-tasks", "List a rule's S3 tasks"), + real("rules-engine rules schedules", "re r schedules", "rules-engine:rules:schedules", "list-rule-schedules", "List schedules for a rule"), + real("data-designer sources fields", "dd sources fields", "data-designer:sources:fields", "list-source-fields", "List fields of a source object", { + flags: [{ name: "type", flag: "--type", required: true, cliExposed: true, enum: ["MDA", "SFDC"] }], + }), + ], + domains: [...catalog.domains, { namespace: "data-designer", aliases: ["dd"] }], + }; + writeFileSync(join(R2, ".gs-superadmin", "catalog.json"), JSON.stringify(catalogPin, null, 2)); + const M2 = join(R2, "acme-pin", "_manifest.json"); + const diff2 = (args = []) => run(CANDIDATES, ["diff", "--manifest", M2, ...args]); + const manifest2 = (verb, args) => run(MANIFEST_SCRIPT, [verb, "--manifest", M2, ...args]); + manifest2("init", ["--slug", "acme-pin", "--base-url", "https://acme.example"]); + const FOUR = ["rules-engine rules events", "rules-engine rules executions", "rules-engine rules s3-tasks", "rules-engine rules schedules"]; + let d = diff2(["--require-decided"]); + const neb = d.json?.notEnumerableBare ?? []; + const undec = (d.json?.undecided ?? []).map((u) => u.path); + checkThat("pin: the table applies — pinFacts.applied true, stamped = catalog version", d.json?.pinFacts?.applied === true && d.json?.pinFacts?.catalogVersion === CLI_PIN_FACTS.cliVersion, d.json?.pinFacts); + checkThat("pin: the four re rules sublists read notEnumerableBare with NO tenant record — needs + the runtime error, tenantRecord null", neb.length === 4 && JSON.stringify(neb.map((n) => n.path)) === JSON.stringify(FOUR) && neb.every((n) => typeof n.needs === "string" && n.needs.startsWith("--") && typeof n.error === "string" && n.tenantRecord === null && typeof n.summary === "string"), neb); + checkThat("pin: none of the four is undecided, and the gate's stderr names none of them (they never gate)", FOUR.every((p) => !undec.includes(p)) && !/re r (events|executions|s3-tasks|schedules)/.test(d.stderr) && d.json?.notEnumerableBareCount === 4, { undec, stderr: d.stderr }); + checkThat("pin: candidateCount still counts the universe — the bucket is part of it", d.json?.candidateCount === d.json.indexedCount + d.json.excludedCount + d.json.blockedCount + d.json.undecidedCount + d.json.notEnumerableBareCount, d.json); + const ddf = (d.json?.undecided ?? []).find((u) => u.path === "data-designer sources fields"); + checkThat("pin: the enum pair keeps its requiredEnumFlags path — dd sources fields is undecided carrying --type over MDA|SFDC, not bucketed", ddf != null && ddf.requiredEnumFlags?.[0]?.flag === "--type" && !neb.some((n) => n.path === "data-designer sources fields"), ddf); + // A tenant record against a bucketed command (the pre-table route) is INERT: + // reported on the entry and in a warning, dropped from the decisions in force. + manifest2("exclude", ["--command", "rules-engine rules events", "--reason", "Per-topic sublist: hard-fails bare", "--no-check", "--topic is required"]); + manifest2("block", ["--command", "rules-engine rules s3-tasks", "--reason", "server error x3", "--recheck-after", "2099-01-01"]); + d = diff2(["--require-decided"]); + const byPath = Object.fromEntries((d.json?.notEnumerableBare ?? []).map((n) => [n.path, n])); + checkThat("pin: an exclusion recorded against a bucketed command reads tenantRecord 'excluded', is not in excluded[] and not counted", byPath["rules-engine rules events"]?.tenantRecord === "excluded" && !(d.json?.excluded ?? []).some((e) => e.path === "rules-engine rules events") && d.json?.excludedCount === 0 && d.json?.excludedLegacyCount === 0, { entry: byPath["rules-engine rules events"], excluded: d.json?.excluded }); + checkThat("pin: a block recorded against a bucketed command reads tenantRecord 'blocked', is not in blocked[] and the gate does not report it as blocked", byPath["rules-engine rules s3-tasks"]?.tenantRecord === "blocked" && !(d.json?.blocked ?? []).some((b) => b.path === "rules-engine rules s3-tasks") && d.json?.blockedCount === 0 && !/BLOCKED candidate/.test(d.stderr), { entry: byPath["rules-engine rules s3-tasks"], stderr: d.stderr }); + checkThat("pin: each inert record draws ONE warning naming the record kind, the flag needed and the lift verb", (d.json?.warnings ?? []).filter((w) => /is excluded per tenant, but at CLI .* not enumerable bare .*--topic.*exclude --remove/.test(w)).length === 1 && (d.json?.warnings ?? []).filter((w) => /is blocked per tenant, but .*block --remove/.test(w)).length === 1, d.json?.warnings); + // Review round: a contradictory manifest (a hand edit — the verbs refuse to + // write both) is still named whatever bucket the command lands in: the + // pairwise contradiction checks run BEFORE the per-pin bucket. + { + const m = JSON.parse(readFileSync(M2, "utf8")); + m.domains_blocked["rules-engine rules events"] = { reason: "server error x3", decidedAt: "2026-09-01T00:00:00.000Z" }; + writeFileSync(M2, JSON.stringify(m, null, 2)); + const dd = diff2(); + checkThat("pin: a bucketed command carrying BOTH an exclusion and a block still draws the excluded+blocked contradiction warning (never hidden by the bucket)", (dd.json?.warnings ?? []).some((w) => /"rules-engine rules events" is both excluded and blocked — contradictory/.test(w)) && (dd.json?.notEnumerableBare ?? []).find((n) => n.path === "rules-engine rules events")?.tenantRecord === "excluded", dd.json?.warnings); + delete m.domains_blocked["rules-engine rules events"]; + writeFileSync(M2, JSON.stringify(m, null, 2)); + } + // The table contradicted by the manifest: a domain INDEXED from a bucketed + // command stays visible as indexed, with a warning — never hidden. + const evFile = join(R2, "events.json"); + writeFileSync(evFile, JSON.stringify({ data: [{ id: "e-1", name: "Evt" }] })); + manifest2("upsert-batch", ["--file", evFile, "--domain", "rules-engine-events", "--id-field", "id", "--name-field", "name", "--no-date-field", "--items-path", "data", "--list-command", "gs-admin --json re r events --topic t1"]); + d = diff2(); + checkThat("pin: a domain indexed from a bucketed command is reported as indexed (never hidden) with a warning naming the contradiction", (d.json?.indexed ?? []).some((i) => i.path === "rules-engine rules events" && i.domains.includes("rules-engine-events")) && !(d.json?.notEnumerableBare ?? []).some((n) => n.path === "rules-engine rules events") && (d.json?.warnings ?? []).some((w) => /is indexed .* but the per-pin table says it is not enumerable bare/.test(w)), { indexed: d.json?.indexed, warnings: d.json?.warnings }); +} + +// ── F-450: the shipped per-pin table holds against the BUNDLED catalog ──────── +// Every id must exist at the pin; a notEnumerableBare entry the catalog has +// started to DECLARE (a required CLI-exposed flag) is dead and must go — the +// filter would drop the command on its own; a scope entry must be a list +// command. The version stamp itself is check-stale-facts' tripwire. +{ + const bundled = JSON.parse(readFileSync(join(SCRIPTS, "..", "reference", "catalog.json"), "utf8")); + const byId = new Map((bundled.commands ?? []).map((c) => [c.id, c])); + checkThat("pin table: stamped for the bundled catalog's cliVersion", CLI_PIN_FACTS.cliVersion === bundled.meta?.cliVersion, { table: CLI_PIN_FACTS.cliVersion, bundled: bundled.meta?.cliVersion }); + for (const [id, fact] of Object.entries(CLI_PIN_FACTS.commands)) { + const c = byId.get(id); + checkThat(`pin table: ${id} exists in the bundled catalog`, c != null, id); + if (!c) continue; + if (fact.notEnumerableBare) + checkThat(`pin table: ${id} still declares no required CLI flag — the entry is live, not dead`, !(c.flags ?? []).some((f) => f.required && f.cliExposed !== false), c.flags); + if (fact.scope) + checkThat(`pin table: ${id} is a list-shaped command`, /^list(-|$)/.test(c.actionKey ?? "") || /^List\b/.test(c.summary ?? ""), { actionKey: c.actionKey, summary: c.summary }); + } +} + // ── diff: filter + statuses ────────────────────────────────────────────────── r = diff(); checkThat("diff: candidate universe is exactly the 8 list-shaped tenant-wide commands", r.json?.candidateCount === 8, r.json); +// F-450 b: this catalog is at a synthetic pin, so the shipped per-pin table is +// NOT applied — the diff says so (pinFacts + a warning naming the stamp) and +// the bucket is empty; the F-219 sublist below stays an ordinary undecided +// candidate under a pin the table does not know. +checkThat( + "diff: under a catalog at another pin the per-pin table is not applied — pinFacts.applied false, a warning names the stamp, notEnumerableBare empty (F-450 b)", + r.json?.pinFacts?.applied === false && r.json?.pinFacts?.stamped === CLI_PIN_FACTS.cliVersion && r.json?.pinFacts?.catalogVersion === "9.9.9" && + (r.json?.warnings ?? []).some((w) => w.includes(`stamped for ${CLI_PIN_FACTS.cliVersion}`)) && r.json?.notEnumerableBareCount === 0 && Array.isArray(r.json?.notEnumerableBare), + { pinFacts: r.json?.pinFacts, warnings: r.json?.warnings } +); const undecidedPaths = (r.json?.undecided ?? []).map((u) => u.path); checkThat( "diff: mutating / required-flag / hidden / describe commands are not candidates", diff --git a/plugins/gs-superadmin/test/fixtures/jo-report/deps/expected-deps-caveats.csv b/plugins/gs-superadmin/test/fixtures/jo-report/deps/expected-deps-caveats.csv index 385dd0b..7ab1254 100644 --- a/plugins/gs-superadmin/test/fixtures/jo-report/deps/expected-deps-caveats.csv +++ b/plugins/gs-superadmin/test/fixtures/jo-report/deps/expected-deps-caveats.csv @@ -2,7 +2,7 @@ caveat Scope: active programs only — 2 of 5 program(s) NOT scanned. A schema change can still break inactive programs someone later reactivates; re-run with --all for full coverage. 5 of 5 program statuses are KB-cached (no live sweep) — the scope and every status shown may rely on stale statuses; verify before acting. "--object matching is FILTER-CONDITIONS-ONLY: C1 mappings carry no object names (only filter conditions do), so query-SELECT/mapping usage of an object is INVISIBLE here — and in practice that can be the dominant usage kind (S5-V live validation found every JO dependent of the acceptance object was mapping-side: `dm deps check` named them all while this view showed none, because few programs use query filters). Treat an empty --object result as ""no FILTER usage found"", never ""no usage"". Objects without live coverage in this run: `Company` — capture each with the corroboration command below and pass it back via `--live-deps ` to render the object-level truth in this report. A rendered live section whose payload objectName differs from the term does NOT count as its coverage (the term may be the object's label — re-capture with the exact system name), and neither does an incomplete capture." -"Field matching ran EXACT-ONLY — no tenant field-aliasing convention is in force (no .gs-superadmin workspace at or above the KB directory), so fields carrying a task-alias prefix (a tenant build standard can prefix every task-built field, e.g. `X_`) did NOT match their unprefixed names and real dependents may be missing from this report. If the tenant aliases fields, declare the pattern in the workspace CONVENTIONS.md `## Field aliasing` section — this report reads it itself — or re-run with `--alias-prefix ''`." +"Field matching ran EXACT-ONLY — no tenant field-aliasing convention is in force (no .gs-superadmin workspace at or above the KB directory), so fields carrying a task-alias prefix (a tenant build standard can prefix every task-built field, e.g. `X_`) did NOT match their unprefixed names and real dependents may be missing from this report. If the tenant aliases fields, declare the pattern in the workspace CONVENTIONS.md `## Field aliasing` section (or in a `/CONVENTIONS.md` override when only this tenant aliases — the report reads that file first) — this report reads it itself — or re-run with `--alias-prefix ''`." "--scan-tokens resolves each program's own token bindings to their bound field (both program generations) and also matches literal token text. Coverage limit: token rows fire only for programs whose journey docs are full-depth in the KB, and locations come from templates with captured KB docs — bindings in stub program docs are invisible, and missing/metadata-only template docs are counted in their own caveats when present." --scan-tokens: 1 referenced template(s) have no KB doc — their tokens could not be scanned (fetch each with `gs-admin --json jo email template --id ''`). --scan-tokens: 1 scanned template doc(s) are metadata-only (no captured body) — subjects were scanned but body/variant tokens could not be seen; a token miss there is silent. diff --git a/plugins/gs-superadmin/test/fixtures/jo-report/deps/expected-report.md b/plugins/gs-superadmin/test/fixtures/jo-report/deps/expected-report.md index a62d155..bcf6059 100644 --- a/plugins/gs-superadmin/test/fixtures/jo-report/deps/expected-report.md +++ b/plugins/gs-superadmin/test/fixtures/jo-report/deps/expected-report.md @@ -37,7 +37,7 @@ These programs filter on a matched object but none of the given field terms — - Scope: active programs only — 2 of 5 program(s) NOT scanned. A schema change can still break inactive programs someone later reactivates; re-run with --all for full coverage. - 5 of 5 program statuses are KB-cached (no live sweep) — the scope and every status shown may rely on stale statuses; verify before acting. - --object matching is FILTER-CONDITIONS-ONLY: C1 mappings carry no object names (only filter conditions do), so query-SELECT/mapping usage of an object is INVISIBLE here — and in practice that can be the dominant usage kind (S5-V live validation found every JO dependent of the acceptance object was mapping-side: `dm deps check` named them all while this view showed none, because few programs use query filters). Treat an empty --object result as "no FILTER usage found", never "no usage". Objects without live coverage in this run: `Company` — capture each with the corroboration command below and pass it back via `--live-deps ` to render the object-level truth in this report. A rendered live section whose payload objectName differs from the term does NOT count as its coverage (the term may be the object's label — re-capture with the exact system name), and neither does an incomplete capture. -- Field matching ran EXACT-ONLY — no tenant field-aliasing convention is in force (no .gs-superadmin workspace at or above the KB directory), so fields carrying a task-alias prefix (a tenant build standard can prefix every task-built field, e.g. `X_`) did NOT match their unprefixed names and real dependents may be missing from this report. If the tenant aliases fields, declare the pattern in the workspace CONVENTIONS.md `## Field aliasing` section — this report reads it itself — or re-run with `--alias-prefix ''`. +- Field matching ran EXACT-ONLY — no tenant field-aliasing convention is in force (no .gs-superadmin workspace at or above the KB directory), so fields carrying a task-alias prefix (a tenant build standard can prefix every task-built field, e.g. `X_`) did NOT match their unprefixed names and real dependents may be missing from this report. If the tenant aliases fields, declare the pattern in the workspace CONVENTIONS.md `## Field aliasing` section (or in a `/CONVENTIONS.md` override when only this tenant aliases — the report reads that file first) — this report reads it itself — or re-run with `--alias-prefix ''`. - --scan-tokens resolves each program's own token bindings to their bound field (both program generations) and also matches literal token text. Coverage limit: token rows fire only for programs whose journey docs are full-depth in the KB, and locations come from templates with captured KB docs — bindings in stub program docs are invisible, and missing/metadata-only template docs are counted in their own caveats when present. - --scan-tokens: 1 referenced template(s) have no KB doc — their tokens could not be scanned (fetch each with `gs-admin --json jo email template --id ''`). - --scan-tokens: 1 scanned template doc(s) are metadata-only (no captured body) — subjects were scanned but body/variant tokens could not be seen; a token miss there is silent. diff --git a/plugins/gs-superadmin/test/jo-report-deps.mjs b/plugins/gs-superadmin/test/jo-report-deps.mjs index bf1fd3e..af8fd85 100644 --- a/plugins/gs-superadmin/test/jo-report-deps.mjs +++ b/plugins/gs-superadmin/test/jo-report-deps.mjs @@ -38,6 +38,7 @@ import { readAliasConvention, resolveAliasPrefix, aliasMatchingLine, + aliasFieldCaveats, prepFieldTerm, fieldTermMatch, isNearMiss, @@ -169,6 +170,50 @@ check("eqTerm: null never matches", !eqTerm(null, "arr") && !eqTerm(undefined, " writeFileSync(CONV, SECTION("- task-alias-prefix-regex: `^[A-Z]_`"), "utf8"); check("readAliasConvention: declared → the inline-code value, compilable", (() => { const r = readAliasConvention(KB); return r.status === "declared" && r.pattern === "^[A-Z]_"; })(), readAliasConvention(KB)); check("readAliasConvention: walk-up from a KB SUBDIR finds the workspace", readAliasConvention(join(KB, "rules-engine")).status === "declared", null); + // F-450 a (ruled 2026-09-15): the workspace file is the DEFAULT for every + // tenant of the workspace; a /CONVENTIONS.md beside the KB OVERRIDES + // it when it exists. Every result names its home and path. + { + const r = readAliasConvention(KB); + check("readAliasConvention: no tenant file → the workspace default, home 'workspace', path = the workspace file (F-450 a)", r.status === "declared" && r.home === "workspace" && r.path === CONV, r); + const TCONV = join(KB, "CONVENTIONS.md"); + writeFileSync(TCONV, SECTION("- task-alias-prefix-regex: `^T_`"), "utf8"); + const t = readAliasConvention(KB); + check("readAliasConvention: a tenant CONVENTIONS.md beside the KB wins over the workspace default — home 'tenant', its own pattern, its own path", t.status === "declared" && t.pattern === "^T_" && t.home === "tenant" && t.path === TCONV, t); + check("readAliasConvention: the override is found from a KB SUBDIR too (the tenant dir is the first segment under the workspace)", readAliasConvention(join(KB, "rules-engine")).pattern === "^T_", readAliasConvention(join(KB, "rules-engine"))); + check("aliasMatchingLine: a tenant-override pattern names the tenant file as its home, never the shared workspace file (F-450 a)", + (() => { const l = aliasMatchingLine(resolveAliasPrefix({ explicit: null, hasFieldTerms: true, kbDir: KB }), true); return l.includes("(`^T_`, from the tenant conventions — the tenant's own CONVENTIONS.md override)") && !l.includes("shared by every tenant"); })(), null); + // An EXISTING tenant file is the home whatever it says: unset there is + // reported against it and never completed from the workspace's + // declaration — two files must never read as one (mutation: a + // fall-through on unset leaks ^[A-Z]_ here). + writeFileSync(TCONV, ["# Tenant", "", "## Naming", "", "- x", ""].join(String.fromCharCode(10)), "utf8"); + const u = readAliasConvention(KB); + check("readAliasConvention: a tenant file WITHOUT the section is unset AGAINST the tenant file — the workspace's declaration never leaks through", u.status === "unset" && u.home === "tenant" && u.path === TCONV && /Field aliasing/.test(u.why), u); + const atRoot = readAliasConvention(WS); + check("readAliasConvention: a start dir AT the workspace root has no tenant — reads the workspace default", atRoot.home === "workspace" && atRoot.status === "declared", atRoot); + // Review round: the workspace's own .gs-superadmin/ is a first-level child + // too, and its CONVENTIONS.md IS the default — never a "tenant override". + const atControl = readAliasConvention(join(WS, ".gs-superadmin")); + check("readAliasConvention: a start dir under the workspace's .gs-superadmin/ is NOT a tenant — home 'workspace' (the F-307 false-provenance class, inverted)", atControl.home === "workspace" && atControl.status === "declared" && atControl.path === CONV, atControl); + // An existing tenant file that declares nothing SHADOWS the workspace + // declaration: the exact-only caveat and the matching line must name the + // tenant file, or the operator edits the workspace file for nothing. + { + const res = resolveAliasPrefix({ explicit: null, hasFieldTerms: true, kbDir: KB }); + const caveat = aliasFieldCaveats({ hasFieldTerms: true, aliasActive: false, unsetWhy: res.unsetWhy, unsetHome: res.home, unsetPath: res.path }).join("\n"); + const line = aliasMatchingLine(res, true); + check("aliasFieldCaveats: an unset TENANT file names itself as the override that shadows the workspace file (review round)", res.home === "tenant" && caveat.includes(TCONV) && /OVERRIDES the workspace CONVENTIONS\.md/.test(caveat) && !/declare the pattern in the workspace CONVENTIONS\.md/.test(caveat), caveat); + check("aliasMatchingLine: the unset arm names the home it read", /read from the tenant's own CONVENTIONS\.md override/.test(line), line); + } + rmSync(TCONV); + check("readAliasConvention: tenant file removed → back to the workspace default", readAliasConvention(KB).home === "workspace" && readAliasConvention(KB).pattern === "^[A-Z]_", readAliasConvention(KB)); + { + const res = resolveAliasPrefix({ explicit: null, hasFieldTerms: true, kbDir: join(ROOT, "conv-none", "kb") }); + const caveat = aliasFieldCaveats({ hasFieldTerms: true, aliasActive: false, unsetWhy: res.unsetWhy, unsetHome: res.home, unsetPath: res.path }).join("\n"); + check("aliasFieldCaveats: with no workspace at all the caveat keeps the generic workspace remedy (home unknown)", res.home == null && /declare the pattern in the workspace CONVENTIONS\.md/.test(caveat), caveat); + } + } writeFileSync(CONV, String.fromCharCode(0xfeff) + SECTION("- task-alias-prefix-regex: `^[A-Z]_`"), "utf8"); check("readAliasConvention: BOM'd CONVENTIONS.md (PS 5.1 Out-File) still parses (T-7 read boundary)", readAliasConvention(KB).status === "declared", readAliasConvention(KB)); writeFileSync(CONV, SECTION("- task-alias-prefix-regex: `^[A-Z]_`").replace("## Field aliasing", "## FIELD ALIASING"), "utf8"); @@ -559,7 +604,7 @@ check("eqTerm: null never matches", !eqTerm(null, "arr") && !eqTerm(undefined, " ); check( "aliasMatchingLine: a conventions-declared pattern still credits the conventions (F-308 regression guard)", - (() => { const l = aliasMatchingLine(resolveAliasPrefix({ explicit: null, hasFieldTerms: true, kbDir: KB }), true); return l.includes("(`^[A-Z]_`, from the tenant conventions)") && !l.includes("--alias-prefix` flag"); })(), + (() => { const l = aliasMatchingLine(resolveAliasPrefix({ explicit: null, hasFieldTerms: true, kbDir: KB }), true); return l.includes("(`^[A-Z]_`, from the tenant conventions — the workspace CONVENTIONS.md, shared by every tenant)") && !l.includes("--alias-prefix` flag"); })(), null ); diff --git a/plugins/gs-superadmin/test/manifest-ops.mjs b/plugins/gs-superadmin/test/manifest-ops.mjs index 9875ba8..5c2133a 100644 --- a/plugins/gs-superadmin/test/manifest-ops.mjs +++ b/plugins/gs-superadmin/test/manifest-ops.mjs @@ -1368,6 +1368,50 @@ check("fingerprint: malformed value rejected (exit 1)", r.code === 1 && /40-char check("f388 partial: a stale id path under --partial fails and routes the rekey to a FULL-list run", r.code === 1 && /resolves to no value on any of the 2 row\(s\)/.test(r.stderr) && /FULL-list upsert-batch with the new --id-field plus --allow-rekey/.test(r.stderr) && /never on a subset/.test(r.stderr), r); } +// ── F-450 (the connectors-chains disagreement): a BLANK date string is a value-less +// present path — stored null, counted dateless, warned about when every row is +// blank; a stored blank from before the rule is cleared on the next upsert ────── +{ + const MB = join(ROOT, "acme-blank", "_manifest.json"); + run("init", ["--manifest", MB, "--slug", "acme-blank", "--base-url", "https://b.example"]); + const chainsFile = join(ROOT, "chains-blank.json"); + writeFileSync(chainsFile, JSON.stringify({ data: [1, 2, 3].map((n) => ({ chainId: `ch-${n}`, name: `Chain ${n}`, modifiedDateStr: "" })) })); + const up = () => run("upsert-batch", ["--manifest", MB, "--file", chainsFile, "--domain", "chains-dom", "--id-field", "chainId", "--name-field", "name", "--date-field", "modifiedDateStr", "--items-path", "data", "--list-command", "gs-admin --json cn chains"]); + r = up(); + const mb = () => JSON.parse(readFileSync(MB, "utf8")); + check("f450 blank: an empty-string date is present-but-valueless — exit 0, added 3, datePresentRows 3 / dateResolvedRows 0, every entry stored null (never the blank)", r.code === 0 && r.json?.added === 3 && r.json?.datePresentRows === 3 && r.json?.dateResolvedRows === 0 && Object.values(mb().inventory).every((e) => e.modified_date === null), r); + check("f450 blank: the field is still RECORDED (a blank is data about the rows, not a dead path) — the operator redates with --allow-redate", mb().domains_indexed["chains-dom"].dateField === "modifiedDateStr", mb().domains_indexed["chains-dom"]); + check("f450 blank: a warning names the all-blank shape, the datelessEntries consequence and the --no-date-field --allow-redate remedy", (r.json?.warnings ?? []).filter((w) => /is an empty string on every one/.test(w) && /datelessEntries/.test(w) && /--no-date-field --allow-redate/.test(w)).length === 1, r.json?.warnings); + r = run("report", ["--manifest", MB]); + check("f450 blank: report counts all three dateless under changeDetection date", r.json?.domains?.["chains-dom"]?.changeDetection === "date" && r.json.domains["chains-dom"].datelessEntries === 3, r.json?.domains?.["chains-dom"]); + // The pre-rule shape (the sandbox's four chains): blanks stored as dates. + // The next full upsert clears them — a repair, counted, status untouched. + { const m = mb(); for (const e of Object.values(m.inventory)) e.modified_date = ""; writeFileSync(MB, JSON.stringify(m, null, 2)); } + r = up(); + check("f450 blank: a stored blank from before the rule is cleared to null on the next upsert — unchanged 3, blankDatesCleared 3, stale 0", r.code === 0 && r.json?.unchanged === 3 && r.json?.blankDatesCleared === 3 && r.json?.stale === 0 && Object.values(mb().inventory).every((e) => e.modified_date === null && e.status === "pending"), r.json); + // Mixed rows: one blank among real dates is per-row data — no warning, the + // blank row null, the dated rows dated (F-392's boundary, kept). + writeFileSync(chainsFile, JSON.stringify({ data: [{ chainId: "ch-1", name: "Chain 1", modifiedDateStr: "" }, { chainId: "ch-2", name: "Chain 2", modifiedDateStr: "2026-02-02T00:00:00Z" }, { chainId: "ch-3", name: "Chain 3", modifiedDateStr: null }] })); + r = up(); + check("f450 blank: a blank among real dates draws no warning; the blank row stays null, the dated row baselines (adopting over null)", r.code === 0 && !(r.json?.warnings ?? []).some((w) => /empty string/.test(w)) && mb().inventory["chains-dom/ch-1"].modified_date === null && mb().inventory["chains-dom/ch-2"].modified_date === "2026-02-02T00:00:00Z" && r.json?.blankDatesCleared === 0, { json: r.json, inv: mb().inventory }); + // Review round — the blank rule reaches every reader, not only the row loop: + // (1) report counts a STORED blank as dateless before any upsert touches it; + // (2) a stored blank under an ADOPTING run (legacy stamp, no dateField key) + // baselines a real incoming date — never a stale flip, never the mass-stale + // advisory; (3) a stored REAL date with an all-blank incoming list keeps its + // date, and the warning says the domain is frozen — not "every entry is + // stored dateless". + { const m = mb(); for (const e of Object.values(m.inventory)) { e.modified_date = ""; e.status = "pending"; } delete m.domains_indexed["chains-dom"].dateField; writeFileSync(MB, JSON.stringify(m, null, 2)); } + r = run("report", ["--manifest", MB]); + check("f450 blank (review): report counts stored blanks as dateless before any upsert repairs them", r.json?.domains?.["chains-dom"]?.datelessEntries === 3, r.json?.domains?.["chains-dom"]); + writeFileSync(chainsFile, JSON.stringify({ data: [1, 2, 3].map((n) => ({ chainId: `ch-${n}`, name: `Chain ${n}`, modifiedDateStr: `2026-03-0${n}T00:00:00Z` })) })); + r = up(); + check("f450 blank (review): stored blanks under an adopting run baseline a real incoming date — baselined 3, stale 0, blankDatesCleared 3, no mass-stale advisory", r.code === 0 && r.json?.baselined === 3 && r.json?.stale === 0 && r.json?.blankDatesCleared === 3 && !(r.json?.warnings ?? []).some((w) => /flipped stale/.test(w)) && Object.values(mb().inventory).every((e) => e.status === "pending" && e.modified_date.startsWith("2026-03")), r.json); + writeFileSync(chainsFile, JSON.stringify({ data: [1, 2, 3].map((n) => ({ chainId: `ch-${n}`, name: `Chain ${n}`, modifiedDateStr: "" })) })); + r = up(); + check("f450 blank (review): an all-blank list over stored real dates keeps the dates and the warning names the FROZEN count, never 'every entry is stored dateless'", r.code === 0 && r.json?.unchanged === 3 && r.json?.blankDatesCleared === 0 && Object.values(mb().inventory).every((e) => e.modified_date.startsWith("2026-03")) && (r.json?.warnings ?? []).some((w) => /3 entry\(ies\) keep a date stored by an earlier list .* FROZEN/.test(w)) && !(r.json?.warnings ?? []).some((w) => /every matched entry is stored dateless/.test(w)), r.json); +} + // ── F-417 / F-418 / F-419 (the 1.0.9 adoption's tester round) ───────────────── { const M6 = join(ROOT, "f417", "_manifest.json"); @@ -1467,7 +1511,7 @@ check("fingerprint: malformed value rejected (exit 1)", r.code === 1 && /40-char // F-419: the count-guard warning names the scope-limited case and points at the notes. r = run("upsert-batch", ["--manifest", M6, "--file", rowsFile("f419-short.json", [{ id: "g1", name: "G1", modifiedDate: oldShared }]), "--domain", "measure-groups", "--items-path", "data", "--id-field", "id", "--name-field", "name"]); const w419 = (r.json?.warnings ?? []).find((w) => /smaller than the domain/.test(w)) ?? ""; - check("f419: the under-pagination warning names the SCOPE-LIMITED case, points at index-scope-notes and names jo email templates", r.code === 0 && /SCOPE-LIMITED domain/.test(w419) && /index-scope-notes\.md/.test(w419) && /jo email templates/.test(w419), w419.slice(0, 200)); + check("f419: the under-pagination warning names the SCOPE-LIMITED case and points at report's scope row and index-scope-notes (F-450: no hand-listed example — the table is the home)", r.code === 0 && /SCOPE-LIMITED domain/.test(w419) && /index-scope-notes\.md/.test(w419) && /domains\.\.scope/.test(w419), w419.slice(0, 200)); } // ── F-429: a renamed or misspelled --domain on a run that identifies an existing @@ -1586,6 +1630,13 @@ check("fingerprint: malformed value rejected (exit 1)", r.code === 1 && /40-char // is counted, never lost to a byDomain-key count. check("c1 report: domainCounts 5 indexed / 4 withAssets / 1 empty, emptyDomains names it", JSON.stringify(rep?.domainCounts) === JSON.stringify({ indexed: 5, withAssets: 4, empty: 1 }) && JSON.stringify(rep?.emptyDomains) === JSON.stringify(["empty-dom"]), rep?.domainCounts); check("c1 report: the empty domain has a row (stamped, no assets)", rep?.domains?.["empty-dom"]?.stamped === true && rep.domains["empty-dom"].byDepth.full === 0 && rep.domains["empty-dom"].datelessEntries === 0, rep?.domains?.["empty-dom"]); + // scope (F-450 c / F-452): derived from each stamp's recorded listCommand + // through doc-lib's per-pin table (the scratch dir has no workspace + // catalog, so the bundled one — at the pin — resolves the lines): the + // empty domain recorded `jo surveys list`, a scope-limited command; the + // others recorded commands with no limit, or nothing. + check("c1 report: pinFacts applied against the bundled catalog", rep?.pinFacts?.applied === true && rep.pinFacts.why === null, rep?.pinFacts); + check("c1 report: scope derived for the domain recorded from jo surveys list — key/path/limit — and null for the others", rep?.domains?.["empty-dom"]?.scope?.key === "journey:surveys:list" && rep.domains["empty-dom"].scope.path === "journey surveys list" && /PUBLISH/.test(rep.domains["empty-dom"].scope.limit) && rep.domains["full-dom"].scope === null && rep.domains["legacy-dom"].scope === null && rep.domains["listonly-dom"].scope === null, Object.fromEntries(Object.entries(rep?.domains ?? {}).map(([k, v]) => [k, v.scope]))); // changeDetection + datelessEntries (F-451): recorded field → date; recorded- // none → none; no key (legacy) → unrecorded; the dateless count is honest // under every state. diff --git a/plugins/gs-superadmin/test/tenant-deps.mjs b/plugins/gs-superadmin/test/tenant-deps.mjs index be95c5c..18b0a03 100644 --- a/plugins/gs-superadmin/test/tenant-deps.mjs +++ b/plugins/gs-superadmin/test/tenant-deps.mjs @@ -1271,7 +1271,7 @@ const runObj = (term, dir) => { check( "e2e conventions: declared pattern read by the SCRIPT — aliased rule found with no --alias-prefix, header names the pattern", conv.status === 0 && convSummary.counts.rulesMatched === 1 && convSummary.aliasPrefix === "^[A-Z]_" && - convMd.includes("task-alias prefix stripping (`^[A-Z]_`, from the tenant conventions)"), + convMd.includes("task-alias prefix stripping (`^[A-Z]_`, from the tenant conventions — the workspace CONVENTIONS.md, shared by every tenant)"), convSummary ); check( From 19412adaaddeb6df9066650f138ad3eb41d1b205 Mon Sep 17 00:00:00 2001 From: BradleyDB <23158057+BradleyDB@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:53:51 -0700 Subject: [PATCH 2/4] C2: ledger line, As-shipped notes (FH-1, RP-4), VALIDATION PR number --- dev/VALIDATION.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev/VALIDATION.md b/dev/VALIDATION.md index 951d2a8..91d1a1e 100644 --- a/dev/VALIDATION.md +++ b/dev/VALIDATION.md @@ -277,8 +277,8 @@ This line is the content change that re-runs the suite on the head; nothing else ## F-450 / F-452 — the C2 arms: scope with no backfill, inert per-pin records, the tenant conventions override, and the sandbox connectors-chains redate (banked 2026-09-15, builder, Session C2) -Owed by: the tester round on the token the C2 handoff mints (branch round-c2-fact-homes; -PR number on the bus's dev comment). Tenant reads: NONE for the scope arm — `scope` is +Owed by: the tester round on the token the C2 handoff mints (branch round-c2-fact-homes, +PR #21). Tenant reads: NONE for the scope arm — `scope` is derived from each manifest's recorded list commands and the shipped per-pin table, so both workspaces read it today without re-listing anything. The walks (setup to the Phase 4 relay; deps-report; refresh if run) read the tenant the way walks always do. The ONE From 480dedb131b9f03cd6a063866dc421a2c8733029 Mon Sep 17 00:00:00 2001 From: BradleyDB <23158057+BradleyDB@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:54:03 -0700 Subject: [PATCH 3/4] handoff hb-20260915-04 (round-c2-fact-homes) --- dev/FEEDBACK.md | 4 ++-- plugins/gs-superadmin/skills/dev-canary/SKILL.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dev/FEEDBACK.md b/dev/FEEDBACK.md index c8cce06..404cdc7 100644 --- a/dev/FEEDBACK.md +++ b/dev/FEEDBACK.md @@ -146,8 +146,8 @@ what the gate counts (F-444). The gate refuses both. Sections from F-360 on; the rule also reads the archive. -Under test: dev · hb-20260915-03 · 2026-09-15 -Blind spots (hb-20260915-03): post-merge close-out of PR 20 on dev: nothing new is under test; the Session C1 arms were measured on the sandbox by two tester rounds (both VALIDATION sections CLEARED). Still fixture-only on any tenant: the positive stale-with-doc direction of docPathsUnknown, remove's count, a non-zero docsForUndocumented, the three legacy-stamp refresh lines, setup's Depth / Not-complete lines and the --deep relay, the mark pathless refusal, the reconcile CWD-mismatch and win32 folder-case refusals. Everything Sessions C2 onward bank. +Under test: round-c2-fact-homes · hb-20260915-04 · 2026-09-15 +Blind spots (hb-20260915-04): no tenant in the builder session: the deps-report walk with a tenant override present on one tenant only, the setup Phase 4 relay's rendering of scope lines (no CI executes skill prose), and the sandbox connectors-chains redate rest on the fixtures until C2-V; the per-pin table under a real workspace catalog at another CLI version rests on the synthetic 9.9.9 fixture; the four sublists' runtime errors in CLI_PIN_FACTS are quoted from both tenants' recorded exclusion reasons, not re-run; the scope arm needs NO tenant read (derived from the manifests' recorded list commands, both read locally)