W6 T1 (eng), T1 (DX): shared transcript consent policy and value grammar

lib/transcript-consent.ts reads transcript_ingest_mode and transcript_repos
together from every merged state root and returns one policy: off in any
root is off, cutoffs and 90-day windows stack, allowlists intersect (EMPTY
denies), and a +repos marker without a readable allowlist denies. It also
owns the per-session and per-page scope checks, the staged-page purge and
the unpublished-range guard, with a --check/--describe/--purge/
--guard-unpublished CLI for gstack-brain-sync. state-root exports its
per-root key reader; transcript_ingest_mode stays out of MERGED_CONFIG_KEYS.
This commit is contained in:
garrytan committed 2026-10-03 21:14:47 +00:00
1 parent ac20ef1e13
commit 9c077137ee
3 files changed
+711 -1

No files matched your search

+1 -1
View File
@@ -55,7 +55,7 @@ export const MERGED_CONFIG_KEYS: Record<string, readonly string[]> = {
};
/** Same parse as `gstack-config get`: last `^key:` line wins, value trimmed, empty = unset. */
function readKeyFromRoot(root: string, key: string): string | null {
export function readKeyFromRoot(root: string, key: string): string | null {
let yaml: string;
try {
yaml = fs.readFileSync(`${root}/config.yaml`, 'utf-8');
+425
View File
@@ -0,0 +1,425 @@
/**
* transcript-consent — the one reader of transcript consent. Reads
* `transcript_ingest_mode` and `transcript_repos` together from every root in
* mergedStateRoots() and returns one policy that gstack-memory-ingest,
* gstack-gbrain-sync and gstack-brain-sync (through this file's CLI) share.
*
* Value grammar: `<base>[@<YYYY-MM-DDTHH:MM:SSZ>][+repos]`, base recent | all |
* off | new. `@<time>` is required for `new` and invalid otherwise; `off+repos`
* is invalid. Readers parse case-insensitively. `+repos` marks a value whose
* scope lives in `transcript_repos`, so a gstack that predates the allowlist
* reads it as unrecognized and ingests nothing.
*
* Across roots: a non-consenting value in any root wins (off, legacy,
* unrecognized). The resolved root must hold the consent itself. Time
* constraints stack (a 90-day window from any `recent`, the latest `new@`
* cutoff), and the repo set is the intersection of every root's allowlist.
*
* CLI (bin/gstack-brain-sync):
* --check exit 0 iff transcript pages may sync at all
* --describe print the effective consent in words
* --purge remove unpublished staged pages outside a scoped consent
* --guard-unpublished drop excluded pages from unpublished sync commits;
* exit 3 when the push must not happen
* Docs: setup-gbrain/memory.md#transcripts.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { canonicalizeRemote } from './gstack-memory-helpers';
import { mergedStateRoots, readKeyFromRoot, resolveStateRoot } from './state-root';
export const MODE_KEY = 'transcript_ingest_mode';
export const REPOS_KEY = 'transcript_repos';
const BRAIN_SYNC_AUTHOR = 'gstack-brain-sync';
const MODE_RE = /^(recent|all|off|new)(?:@(\d{4}-\d{2}-\d{2}t\d{2}:\d{2}:\d{2}z))?(\+repos)?$/i;
const ROLLING_DAYS = 90;
export type TranscriptBase = 'recent' | 'all' | 'off' | 'new';
export interface TranscriptModeValue {
base: TranscriptBase;
/** ISO-8601 UTC cutoff (`new` only). */
cutoff: string | null;
/** The value carries the `+repos` allowlist marker. */
repos: boolean;
}
/** True when `s` is exactly `YYYY-MM-DDTHH:MM:SSZ` and names a real instant. */
export function isUtcSecond(s: string): boolean {
const ms = Date.parse(s);
return Number.isFinite(ms) && new Date(ms).toISOString().replace('.000Z', 'Z') === s;
}
/** Parse one stored value (case-insensitive). null = not valid grammar. */
export function parseTranscriptMode(raw: string): TranscriptModeValue | null {
const m = MODE_RE.exec(raw.trim());
if (!m) return null;
const base = m[1].toLowerCase() as TranscriptBase;
const cutoff = m[2] ? m[2].toUpperCase() : null;
if ((base === 'new') !== (cutoff !== null)) return null;
if (cutoff !== null && !isUtcSecond(cutoff)) return null;
if (base === 'off' && m[3]) return null;
return { base, cutoff, repos: m[3] !== undefined };
}
export type TranscriptConsentReason =
| 'recent'
| 'all'
| 'new'
| 'off'
| 'not-set'
| 'legacy'
| 'unrecognized'
| 'repos-unreadable';
export interface TranscriptConsent {
/** Every root consents; transcripts may be walked without an override. */
affirmative: boolean;
/** recent = rolling 90 days (mtime, as before), new = cutoff only, all = no time limit. */
window: 'recent' | 'all' | 'new' | null;
reason: TranscriptConsentReason;
/** The resolved root's stored value (null when absent). */
value: string | null;
/** Latest `new@` cutoff across roots. Applies even under an explicit override. */
cutoff?: string;
/** Canonical remotes allowed across roots ([] = none). Applies even under an override. */
repos?: string[];
}
export interface TranscriptPolicy extends TranscriptConsent {
/** Roots whose config.yaml sets either key. */
roots: string[];
}
export type TranscriptExclusion = 'pre-cutoff' | 'missing-start' | 'not-allowlisted';
export function parseRepoList(raw: string): string[] {
const out: string[] = [];
for (const part of raw.split(',')) {
const remote = canonicalizeRemote(part);
if (remote && !out.includes(remote)) out.push(remote);
}
return out;
}
/** Read the effective transcript consent across `roots` (resolved root first). */
export function readTranscriptConsent(roots: string[] = mergedStateRoots()): TranscriptPolicy {
const resolvedValue = roots.length > 0 ? readKeyFromRoot(roots[0], MODE_KEY) : null;
let reason: TranscriptConsentReason | null = resolvedValue === null ? 'not-set' : null;
let rolling = false;
let cutoff: string | undefined;
let repos: string[] | undefined;
const contributing: string[] = [];
for (const root of roots) {
const raw = readKeyFromRoot(root, MODE_KEY);
const list = readKeyFromRoot(root, REPOS_KEY);
if (raw === null && list === null) continue;
contributing.push(root);
let marker = false;
if (raw !== null) {
const v = raw.trim().toLowerCase();
const parsed = parseTranscriptMode(v);
let rootReason: TranscriptConsentReason | null = null;
if (!parsed) rootReason = /^[a-e]$/.test(v) || v === 'incremental' ? 'legacy' : 'unrecognized';
else if (parsed.base === 'off') rootReason = 'off';
else {
marker = parsed.repos;
if (parsed.base === 'recent') rolling = true;
if (parsed.cutoff && (!cutoff || parsed.cutoff > cutoff)) cutoff = parsed.cutoff;
}
reason ??= rootReason;
}
const entries = list === null ? [] : parseRepoList(list);
if (entries.length > 0) repos = repos ? repos.filter((r) => entries.includes(r)) : entries;
else if (marker) {
repos = [];
reason ??= 'repos-unreadable';
}
}
const affirmative = reason === null;
const window = !affirmative ? null : rolling ? 'recent' : cutoff ? 'new' : 'all';
const policy: TranscriptPolicy = {
affirmative,
window,
reason: reason ?? (window as TranscriptConsentReason),
value: resolvedValue,
roots: contributing,
};
if (cutoff !== undefined) policy.cutoff = cutoff;
if (repos !== undefined) policy.repos = repos;
return policy;
}
/** A cutoff or an allowlist narrows consent to some sessions. */
export function isScoped(c: TranscriptConsent): boolean {
return c.cutoff !== undefined || c.repos !== undefined;
}
/** Transcript pages may sync at all: consent, and a repo set that is not empty. */
export function allowsTranscripts(c: TranscriptConsent): boolean {
return c.affirmative && !(c.repos !== undefined && c.repos.length === 0);
}
/** Short stable id of the allowlist, recorded with a memory stage so a scope change restages. */
export function reposHash(repos: string[]): string {
return createHash('sha256').update([...repos].sort().join('\n')).digest('hex').slice(0, 12);
}
/** The `new@` cutoff check on a session's first record timestamp (never mtime). */
export function cutoffExclusion(c: TranscriptConsent, startedAt: string | null | undefined): 'pre-cutoff' | 'missing-start' | null {
if (c.cutoff === undefined) return null;
const ms = startedAt ? Date.parse(startedAt) : NaN;
if (!Number.isFinite(ms)) return 'missing-start';
return ms < Date.parse(c.cutoff) ? 'pre-cutoff' : null;
}
/** The allowlist check on a session's canonical git remote. */
export function repoExclusion(c: TranscriptConsent, remote: string | null | undefined): 'not-allowlisted' | null {
if (c.repos === undefined) return null;
const canonical = canonicalizeRemote(remote ?? '');
return canonical && c.repos.includes(canonical) ? null : 'not-allowlisted';
}
/** Why one session is outside the scope, or null when it is inside. Consent itself is checked by callers. */
export function sessionExclusion(
c: TranscriptConsent,
session: { startedAt?: string | null; remote?: string | null },
): TranscriptExclusion | null {
return cutoffExclusion(c, session.startedAt) ?? repoExclusion(c, session.remote);
}
function formatCutoff(cutoff: string): string {
return `${cutoff.slice(0, 10)} ${cutoff.slice(11, 19)} UTC`;
}
/** The effective consent in words: window or cutoff, up to 3 repos, contributing roots. */
export function describeTranscriptPolicy(p: TranscriptPolicy): string {
let time: string;
if (p.window === 'recent') time = `sessions from the last ${ROLLING_DAYS} days${p.cutoff ? ` that started after ${formatCutoff(p.cutoff)}` : ''}`;
else if (p.cutoff) time = `sessions that started after ${formatCutoff(p.cutoff)}`;
else time = 'all history';
let scope: string;
if (p.repos === undefined) scope = 'every repo your repo policy allows';
else if (p.repos.length === 0) scope = 'no repo (the repo allowlists share no repo, or one is unreadable)';
else {
const shown = p.repos.slice(0, 3).join(', ');
scope = `${p.repos.length === 1 ? 'repo' : 'repos'} ${shown}${p.repos.length > 3 ? ` +${p.repos.length - 3} more` : ''}`;
}
const roots = p.roots.length > 0 ? `; config: ${p.roots.join(', ')}` : '';
if (!p.affirmative) return `not consented (${p.reason}); scope if overridden: ${time}; ${scope}${roots}`;
return `${time}; ${scope}${roots}`;
}
// ── Staged pages ────────────────────────────────────────────────────────────
/** Frontmatter fields of a staged page (flat `key: value` lines). */
export function pageFrontmatter(text: string): Record<string, string> {
const out: Record<string, string> = {};
if (!text.startsWith('---\n')) return out;
const end = text.indexOf('\n---', 4);
if (end < 0) return out;
for (const line of text.slice(4, end).split('\n')) {
const m = /^([a-z_]+):\s?(.*)$/.exec(line);
if (m) out[m[1]] = m[2].trim();
}
return out;
}
/**
* Why a staged page under transcripts/ must not sync, or null. Pages typed as
* another memory type (artifacts staged in a remote-http run dir) are never
* excluded here. Without consent every transcript page is excluded.
*/
export function pageExclusion(c: TranscriptConsent, text: string): TranscriptExclusion | 'not-consented' | null {
const fm = pageFrontmatter(text);
if (fm.type && fm.type !== 'transcript') return null;
if (!c.affirmative) return 'not-consented';
return sessionExclusion(c, { startedAt: fm.session_started_at || fm.start_time, remote: fm.git_remote });
}
function git(home: string, args: string[]): { ok: boolean; out: string } {
const r = spawnSync('git', ['-C', home, ...args], { encoding: 'utf-8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 });
return { ok: r.status === 0, out: r.stdout || '' };
}
/** origin/<branch> for the checked-out branch, or null (no repo, detached, never pushed). */
function upstreamRef(home: string): string | null {
if (!fs.existsSync(path.join(home, '.git'))) return null;
const branch = git(home, ['rev-parse', '--abbrev-ref', 'HEAD']).out.trim();
if (!branch || branch === 'HEAD') return null;
const ref = `origin/${branch}`;
return git(home, ['rev-parse', '--verify', '--quiet', ref]).ok ? ref : null;
}
function walkMarkdown(dir: string, out: string[] = []): string[] {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return out;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) walkMarkdown(full, out);
else if (e.isFile() && e.name.endsWith('.md')) out.push(full);
}
return out;
}
/** Drop ingest fingerprints so a later widening re-stages these sources. */
function clearFingerprints(home: string, sourcePaths: string[]): void {
if (sourcePaths.length === 0) return;
const statePath = path.join(home, '.transcript-ingest-state.json');
let state: { sessions?: Record<string, unknown> };
try {
state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
} catch {
return;
}
if (!state.sessions) return;
let changed = false;
for (const p of sourcePaths) {
if (p in state.sessions) {
delete state.sessions[p];
changed = true;
}
}
if (!changed) return;
const tmp = `${statePath}.tmp.${process.pid}`;
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf-8');
fs.renameSync(tmp, statePath);
}
/**
* Remove staged transcript pages that are not yet published and fall outside
* a scoped consent (cutoff or allowlist). Without consent pages are kept, as
* before: they wait in the queue until the user consents. Published pages are
* left alone; removing them from the brain is a separate operation.
*/
export function purgeStagedTranscripts(home: string, c: TranscriptConsent): number {
if (!c.affirmative || !isScoped(c)) return 0;
const dir = path.join(home, 'transcripts');
if (!fs.existsSync(dir)) return 0;
const ref = upstreamRef(home);
const published = new Set(
ref ? git(home, ['ls-tree', '-r', '--name-only', ref, '--', 'transcripts']).out.split('\n').filter(Boolean) : [],
);
const sources: string[] = [];
let removed = 0;
for (const file of walkMarkdown(dir)) {
const rel = path.relative(home, file).split(path.sep).join('/');
if (published.has(rel)) continue;
let text: string;
try {
text = fs.readFileSync(file, 'utf-8');
} catch {
continue;
}
if (pageExclusion(c, text) === null) continue;
fs.rmSync(file, { force: true });
removed++;
const source = pageFrontmatter(text).source_path;
if (source) sources.push(source);
}
clearFingerprints(home, sources);
return removed;
}
/** Queue a path again (same record shape as bin/gstack-brain-enqueue). */
function enqueue(home: string, rel: string, seq: number): void {
const spool = path.join(home, '.brain-queue.d');
fs.mkdirSync(spool, { recursive: true });
const tmp = path.join(spool, `.tmp-${process.pid}-g${seq}`);
fs.writeFileSync(tmp, JSON.stringify({ file: rel, ts: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') }) + '\n');
fs.renameSync(tmp, path.join(spool, `${Math.floor(Date.now() / 1000)}-${process.pid}-g${seq}.json`));
}
export interface GuardResult {
status: 'clean' | 'rewritten' | 'blocked';
removed: string[];
message?: string;
}
/**
* Before a push: scan the unpublished range for transcript pages outside the
* current consent. When some exist and every unpublished commit is
* gstack-brain-sync's own, soft-reset to the upstream, unstage the excluded
* pages and recommit, so nothing excluded is ever pushed. Otherwise block.
* Under a scoped consent the excluded pages are also purged from disk (with
* their fingerprints). Pages removed while consent is absent stay on disk and
* are queued again, so they wait for consent like never-committed pages.
*/
export function guardUnpublished(home: string, c: TranscriptConsent): GuardResult {
if (!fs.existsSync(path.join(home, '.git'))) return { status: 'clean', removed: [] };
const ref = upstreamRef(home);
const range = ref ? `${ref}..HEAD` : 'HEAD';
const touched = [...new Set(git(home, ['log', '--format=', '--name-only', range, '--', 'transcripts']).out.split('\n').filter(Boolean))];
if (touched.length === 0) return { status: 'clean', removed: [] };
purgeStagedTranscripts(home, c);
const inHead = new Set(git(home, ['ls-tree', '-r', '--name-only', 'HEAD', '--', 'transcripts']).out.split('\n').filter(Boolean));
const excluded: string[] = [];
for (const rel of touched) {
let text: string | null = null;
if (inHead.has(rel)) text = git(home, ['show', `HEAD:${rel}`]).out;
else {
const sha = git(home, ['log', '-n1', '--format=%H', '--diff-filter=AM', range, '--', rel]).out.trim();
if (sha) text = git(home, ['show', `${sha}:${rel}`]).out;
}
if (text !== null && pageExclusion(c, text) !== null) excluded.push(rel);
}
if (excluded.length === 0) return { status: 'clean', removed: [] };
const authors = new Set(git(home, ['log', '--no-merges', '--format=%an', range]).out.split('\n').filter(Boolean));
if (!ref) return { status: 'blocked', removed: [], message: `push blocked: ${excluded.length} transcript page(s) outside your transcript consent are in unpushed commits and there is no upstream branch to rewrite against` };
if ([...authors].some((a) => a !== BRAIN_SYNC_AUTHOR)) {
return { status: 'blocked', removed: [], message: `push blocked: ${excluded.length} transcript page(s) outside your transcript consent are in unpushed commits that include your own commits; remove them (git rm --cached) and amend, then sync again` };
}
if (!git(home, ['diff', '--cached', '--quiet']).ok) {
return { status: 'blocked', removed: [], message: 'push blocked: excluded transcript pages are in unpushed commits and the index has other staged changes' };
}
if (!git(home, ['reset', '-q', '--soft', ref]).ok) return { status: 'blocked', removed: [], message: 'push blocked: could not rewrite unpushed sync commits' };
const headExcluded = excluded.filter((rel) => inHead.has(rel));
const published = new Set(git(home, ['ls-tree', '-r', '--name-only', ref, '--', ...headExcluded]).out.split('\n').filter(Boolean));
const restore = headExcluded.filter((rel) => published.has(rel));
const drop = headExcluded.filter((rel) => !published.has(rel));
if (restore.length > 0) git(home, ['reset', '-q', ref, '--', ...restore]);
if (drop.length > 0) git(home, ['rm', '-q', '--cached', '--ignore-unmatch', '--', ...drop]);
if (!git(home, ['diff', '--cached', '--quiet']).ok) {
const ts = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const commit = spawnSync('git', ['-C', home, '-c', 'user.email=gstack@localhost', '-c', `user.name=${BRAIN_SYNC_AUTHOR}`, 'commit', '-q', '-m', `sync: rewritten without ${excluded.length} excluded transcript page(s) | ${ts}`], { encoding: 'utf-8', timeout: 30_000 });
if (commit.status !== 0) return { status: 'blocked', removed: [], message: 'push blocked: could not recommit unpushed sync changes' };
}
if (!c.affirmative) {
let seq = 0;
for (const rel of drop) if (fs.existsSync(path.join(home, rel))) enqueue(home, rel, ++seq);
}
return { status: 'rewritten', removed: excluded };
}
if (import.meta.main) {
const cmd = process.argv[2];
const home = resolveStateRoot();
const policy = readTranscriptConsent();
if (cmd === '--check') process.exit(allowsTranscripts(policy) ? 0 : 1);
if (cmd === '--describe') {
console.log(describeTranscriptPolicy(policy));
process.exit(0);
}
if (cmd === '--purge') {
const n = purgeStagedTranscripts(home, policy);
if (n > 0) console.error(`BRAIN_SYNC: removed ${n} staged transcript pages outside the new scope`);
process.exit(0);
}
if (cmd === '--guard-unpublished') {
const r = guardUnpublished(home, policy);
if (r.status === 'rewritten') console.error(`BRAIN_SYNC: removed ${r.removed.length} excluded transcript pages from unpublished sync commits`);
if (r.status === 'blocked') {
console.error(`BRAIN_SYNC: ${r.message}`);
process.exit(3);
}
process.exit(0);
}
console.error('Usage: bun run lib/transcript-consent.ts --check | --describe | --purge | --guard-unpublished');
process.exit(2);
}
+285
View File
@@ -0,0 +1,285 @@
/**
* lib/transcript-consent.ts: the value grammar, the multi-root policy, the
* per-session and per-page scope checks, and the parity of the bash validator
* and remote canonicalizer in bin/gstack-config with their TS twins.
*/
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { canonicalizeRemote } from '../lib/gstack-memory-helpers';
import {
describeTranscriptPolicy,
pageExclusion,
parseTranscriptMode,
readTranscriptConsent,
reposHash,
sessionExclusion,
type TranscriptConsent,
} from '../lib/transcript-consent';
const CONFIG_BIN = join(import.meta.dir, '..', 'bin', 'gstack-config');
const TS = '2026-10-03T17:00:00Z';
/** Every grammar combination: base x (cutoff | none) x (+repos | none). */
function combinations(): Array<{ value: string; valid: boolean }> {
const out: Array<{ value: string; valid: boolean }> = [];
for (const base of ['recent', 'all', 'off', 'new']) {
for (const at of ['', `@${TS}`]) {
for (const marker of ['', '+repos']) {
const valid = (base === 'new') === (at !== '') && !(base === 'off' && marker !== '');
out.push({ value: `${base}${at}${marker}`, valid });
}
}
}
return out;
}
const INVALID_FORMS = [
`new@2026-10-03T17:00:00+00:00`, // offsets are rejected; only Z
`new@2026-10-03T17:00:00.000Z`, // milliseconds are not part of the grammar
`new@2026-10-03 17:00:00Z`,
`new@2026-10-03`,
`new@2026-02-30T00:00:00Z`, // not a real day
`new@2026-13-01T00:00:00Z`,
`new@2026-10-03T24:00:00Z`,
`new+repos@${TS}`, // @ precedes +repos
`new@`,
'recent+repo',
'yes',
'A',
'incremental',
'',
];
describe('grammar', () => {
test('every base x cutoff x marker combination', () => {
for (const { value, valid } of combinations()) {
const parsed = parseTranscriptMode(value);
expect({ value, valid: parsed !== null }).toEqual({ value, valid });
if (parsed) {
expect(parsed.base).toBe(value.split(/[@+]/)[0] as never);
expect(parsed.cutoff).toBe(value.includes('@') ? TS : null);
expect(parsed.repos).toBe(value.endsWith('+repos'));
}
}
});
test('readers are case-insensitive; offsets, bad dates and misplaced parts are rejected', () => {
expect(parseTranscriptMode(`NEW@2026-10-03t17:00:00z+REPOS`)).toEqual({ base: 'new', cutoff: TS, repos: true });
expect(parseTranscriptMode(' Recent ')).toEqual({ base: 'recent', cutoff: null, repos: false });
expect(parseTranscriptMode('new@2028-02-29T00:00:00Z')).not.toBeNull();
for (const value of INVALID_FORMS) expect({ value, parsed: parseTranscriptMode(value) }).toEqual({ value, parsed: null });
});
test('gstack-config set accepts exactly the canonical valid forms and rejects the rest with the file unchanged', () => {
const root = mkdtempSync(join(tmpdir(), 'gstack-grammar-'));
try {
const env = { ...process.env, GSTACK_STATE_ROOT: root };
const run = (...args: string[]) => spawnSync('bash', [CONFIG_BIN, ...args], { encoding: 'utf-8', timeout: 30_000, env });
expect(run('set', 'transcript_repos', 'github.com/acme/app').status).toBe(0);
for (const { value, valid } of combinations()) {
const r = run('set', 'transcript_ingest_mode', value);
expect({ value, status: r.status }).toEqual({ value, status: valid ? 0 : 1 });
}
run('set', 'transcript_ingest_mode', 'recent');
for (const value of [...INVALID_FORMS.filter(Boolean), 'Recent', `new@2026-10-03t17:00:00z`]) {
const r = run('set', 'transcript_ingest_mode', value);
expect({ value, status: r.status }).toEqual({ value, status: 1 });
expect(r.stderr).toContain(`Error: transcript_ingest_mode '${value}' not recognized. Valid values: recent, all, off. Existing value left unchanged.`);
expect(r.stderr).toContain('new@<YYYY-MM-DDTHH:MM:SSZ>');
}
expect(run('get', 'transcript_ingest_mode').stdout).toBe('recent+repos');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('the bash remote canonicalizer matches canonicalizeRemote', () => {
const inputs = [
'https://github.com/Acme/App.git',
'git@github.com:acme/app.git',
'ssh://git@github.com/acme/app',
'http://user@gitlab.example.com/group/sub/repo.git/',
' "github.com/acme/app/" ',
'github.com//acme///app',
'/srv/git/repo/.git',
'file:///srv/git/repo',
];
const script = `. /dev/null; source <(sed -n '/^canonical_remote() {/,/^}/p' "${CONFIG_BIN}"); for x in "$@"; do canonical_remote "$x"; echo; done`;
const r = spawnSync('bash', ['-c', script, 'bash', ...inputs], { encoding: 'utf-8', timeout: 30_000 });
expect(r.status).toBe(0);
expect(r.stdout.split('\n').slice(0, inputs.length)).toEqual(inputs.map((i) => canonicalizeRemote(i)));
});
});
/** The normalizer shipped before this change (bin/gstack-memory-ingest.ts at v1.91.16.0), verbatim in behavior. */
function olderNormalizerConsents(value: string | null): boolean {
const v = (value ?? '').trim().toLowerCase();
return value !== null && (v === 'recent' || v === 'all');
}
describe('fail-closed encoding', () => {
test('a gstack without new@ or allowlist support reads every new value form as no consent', () => {
for (const value of [`new@${TS}`, `new@${TS}+repos`, 'recent+repos', 'all+repos']) {
expect({ value, consents: olderNormalizerConsents(value) }).toEqual({ value, consents: false });
}
});
test('the current normalizer treats any unrecognized value as no consent', () => {
const root = mkdtempSync(join(tmpdir(), 'gstack-consent-'));
try {
for (const value of ['yes', 'new', `recent@${TS}`, 'off+repos', 'new@tomorrow', 'recent+repos+repos']) {
writeFileSync(join(root, 'config.yaml'), `transcript_ingest_mode: ${value}\n`);
expect(readTranscriptConsent([root])).toMatchObject({ affirmative: false, reason: 'unrecognized', value });
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
describe('effective consent across state roots', () => {
const roots: string[] = [];
const root = (yaml: string | null) => {
const dir = mkdtempSync(join(tmpdir(), 'gstack-root-'));
roots.push(dir);
if (yaml !== null) writeFileSync(join(dir, 'config.yaml'), yaml);
return dir;
};
const cleanup = () => roots.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
test('off in one root plus new@ in another gives no consent', () => {
try {
const p = readTranscriptConsent([root(`transcript_ingest_mode: new@${TS}\n`), root('transcript_ingest_mode: off\n')]);
expect(p).toMatchObject({ affirmative: false, reason: 'off', cutoff: TS });
const q = readTranscriptConsent([root('transcript_ingest_mode: off\n'), root(`transcript_ingest_mode: new@${TS}\n`)]);
expect(q).toMatchObject({ affirmative: false, reason: 'off' });
} finally {
cleanup();
}
});
test('time constraints stack: the latest cutoff and any 90-day window', () => {
try {
const later = '2026-11-01T00:00:00Z';
expect(readTranscriptConsent([root(`transcript_ingest_mode: new@${TS}\n`), root(`transcript_ingest_mode: new@${later}\n`)]))
.toMatchObject({ affirmative: true, window: 'new', reason: 'new', cutoff: later });
expect(readTranscriptConsent([root('transcript_ingest_mode: all\n'), root(`transcript_ingest_mode: new@${TS}\n`)]))
.toMatchObject({ affirmative: true, window: 'new', cutoff: TS });
expect(readTranscriptConsent([root(`transcript_ingest_mode: new@${TS}\n`), root('transcript_ingest_mode: recent\n')]))
.toMatchObject({ affirmative: true, window: 'recent', cutoff: TS });
const all = readTranscriptConsent([root('transcript_ingest_mode: all\n'), root(null)]);
expect(all).toMatchObject({ affirmative: true, window: 'all' });
expect(all.cutoff).toBeUndefined();
} finally {
cleanup();
}
});
test('allowlists intersect; disjoint lists give EMPTY and nothing is ingested', () => {
try {
const a = root('transcript_ingest_mode: recent+repos\ntranscript_repos: github.com/acme/app,github.com/acme/api\n');
const b = root('transcript_repos: https://github.com/acme/api.git,github.com/acme/web\n');
expect(readTranscriptConsent([a, b])).toMatchObject({ affirmative: true, repos: ['github.com/acme/api'], roots: [a, b] });
const c = root('transcript_ingest_mode: all+repos\ntranscript_repos: github.com/other/x\n');
const empty = readTranscriptConsent([a, c]);
expect(empty.repos).toEqual([]);
for (const remote of ['github.com/acme/app', 'github.com/other/x', '']) {
expect(sessionExclusion(empty, { startedAt: TS, remote })).toBe('not-allowlisted');
}
} finally {
cleanup();
}
});
test('a +repos marker without a readable allowlist denies', () => {
try {
for (const yaml of ['transcript_ingest_mode: recent+repos\n', 'transcript_ingest_mode: recent+repos\ntranscript_repos: \n', 'transcript_ingest_mode: all+repos\ntranscript_repos: ,\n']) {
expect(readTranscriptConsent([root(yaml)])).toMatchObject({ affirmative: false, reason: 'repos-unreadable', repos: [] });
}
} finally {
cleanup();
}
});
test('the resolved root must hold the consent; other roots only restrict', () => {
try {
expect(readTranscriptConsent([root(null), root('transcript_ingest_mode: all\n')])).toMatchObject({ affirmative: false, reason: 'not-set' });
expect(readTranscriptConsent([root('transcript_ingest_mode: recent\n'), root('transcript_ingest_mode: A\n')])).toMatchObject({ affirmative: false, reason: 'legacy' });
} finally {
cleanup();
}
});
});
describe('scope checks', () => {
const scoped: TranscriptConsent = { affirmative: true, window: 'new', reason: 'new', value: `new@${TS}+repos`, cutoff: TS, repos: ['github.com/acme/app'] };
test('sessions: the cutoff uses the start time; the allowlist uses the canonical remote', () => {
expect(sessionExclusion(scoped, { startedAt: '2026-10-03T16:59:59Z', remote: 'github.com/acme/app' })).toBe('pre-cutoff');
expect(sessionExclusion(scoped, { startedAt: TS, remote: 'git@github.com:Acme/App.git' })).toBeNull();
expect(sessionExclusion(scoped, { startedAt: '', remote: 'github.com/acme/app' })).toBe('missing-start');
expect(sessionExclusion(scoped, { startedAt: 'not a date', remote: 'github.com/acme/app' })).toBe('missing-start');
expect(sessionExclusion(scoped, { startedAt: '2026-10-04T00:00:00Z', remote: 'github.com/acme/web' })).toBe('not-allowlisted');
expect(sessionExclusion({ affirmative: true, window: 'recent', reason: 'recent', value: 'recent' }, { startedAt: null, remote: null })).toBeNull();
});
test('pages: frontmatter decides; artifact pages are never excluded; no consent excludes every transcript page', () => {
const page = (fm: string) => `---\n${fm}\n---\n\nbody\n`;
expect(pageExclusion(scoped, page(`type: transcript\nsession_started_at: 2026-10-04T00:00:00.000Z\ngit_remote: github.com/acme/app`))).toBeNull();
expect(pageExclusion(scoped, page(`type: transcript\nstart_time: 2026-09-01T00:00:00Z\ngit_remote: github.com/acme/app`))).toBe('pre-cutoff');
expect(pageExclusion(scoped, page('type: transcript\ngit_remote: github.com/acme/app'))).toBe('missing-start');
expect(pageExclusion(scoped, page('type: learning'))).toBeNull();
expect(pageExclusion(scoped, '# no frontmatter\n')).toBe('missing-start');
const recent: TranscriptConsent = { affirmative: true, window: 'recent', reason: 'recent', value: 'recent' };
expect(pageExclusion(recent, '# no frontmatter\n')).toBeNull();
const off: TranscriptConsent = { affirmative: false, window: null, reason: 'off', value: 'off' };
expect(pageExclusion(off, page('type: transcript'))).toBe('not-consented');
});
test('the allowlist hash is order-independent', () => {
expect(reposHash(['b', 'a'])).toBe(reposHash(['a', 'b']));
expect(reposHash(['a'])).not.toBe(reposHash(['a', 'b']));
});
});
describe('consent in words', () => {
test('window or cutoff, up to three repos then +N more, contributing roots', () => {
const base = { affirmative: true, reason: 'new' as const, value: 'x', roots: ['/r1', '/r2'] };
expect(describeTranscriptPolicy({ ...base, window: 'recent', reason: 'recent' })).toBe('sessions from the last 90 days; every repo your repo policy allows; config: /r1, /r2');
expect(describeTranscriptPolicy({ ...base, window: 'new', cutoff: TS, repos: ['a/b/c', 'd/e/f', 'g/h/i', 'j/k/l', 'm/n/o'] }))
.toBe('sessions that started after 2026-10-03 17:00:00 UTC; repos a/b/c, d/e/f, g/h/i +2 more; config: /r1, /r2');
expect(describeTranscriptPolicy({ ...base, window: 'all', reason: 'all', repos: ['a/b/c'], roots: [] })).toBe('all history; repo a/b/c');
});
});
describe('CLI', () => {
test('--check exits 0 only when transcript pages may sync', () => {
const root = mkdtempSync(join(tmpdir(), 'gstack-check-'));
mkdirSync(join(root, 'legacy'));
try {
const check = () =>
spawnSync('bun', ['run', join(import.meta.dir, '..', 'lib', 'transcript-consent.ts'), '--check'], {
encoding: 'utf-8',
timeout: 30_000,
env: { ...process.env, GSTACK_STATE_ROOT: root, GSTACK_TEST_LEGACY_ROOT: join(root, 'legacy') },
}).status;
expect(check()).toBe(1);
for (const [yaml, status] of [
['transcript_ingest_mode: recent\n', 0],
[`transcript_ingest_mode: new@${TS}\n`, 0],
['transcript_ingest_mode: off\n', 1],
['transcript_ingest_mode: recent+repos\n', 1],
['transcript_ingest_mode: recent+repos\ntranscript_repos: github.com/a/b\n', 0],
] as const) {
writeFileSync(join(root, 'config.yaml'), yaml);
expect({ yaml, status: check() }).toEqual({ yaml, status });
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});