Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -818,7 +818,7 @@ A risk baseline covers every selected System, active Component, material provide

Operating ownership can use existing implemented Control procedures and approved program Documents. Dated rehearsal Evidence selects the procedures actually used, or management selects their IDs on the Program. Existing Document approval revisions and normal Control oversight remain authoritative. Material changes follow the existing review process; no separate Document or duplicate independent approval is introduced solely for this group.

Tokenized resource navigation projects the same snapshot-scoped workflow section used by root navigation. Previously, detail requests repeated full synchronous workflow and Git-history work on the HTTP thread after the shared worker had already calculated it. Detail now joins or reuses that worker calculation, retains each caller's deadline and verifies live file and Git signatures before responding. The 10-second request budgets remain unchanged. Tests cover warm computation bounds, concurrent cold requests, recovery after an earlier caller deadline, transient Git failures and invalidation after source changes.
Tokenized resource navigation projects the same snapshot-scoped workflow section used by root navigation. Previously, detail requests repeated full synchronous workflow and Git-history work on the HTTP thread after the shared worker had already calculated it. Detail now joins or reuses that worker calculation, retains each caller's deadline and verifies live file and Git signatures before responding. Cold section and resource calculations have a separate two-minute aggregate budget, while every individual Git subprocess remains limited to ten seconds. Browser reads opt into `Prefer: respond-async`: after a one-second response wait, a still-running shared calculation returns `202` with a token-bound pending status and `Retry-After: 1`. Polls join that same bounded read, retain loading UI, and deliver data only after live snapshot verification. Actual failures remain errors with an explicit Retry loading action. Other HTTP clients retain the wait-for-completion contract. Tests cover concurrent pending sections and details, effective subprocess limits, bounded failures, explicit retries and source-change invalidation.

Finalized operating proof freezes explicit completion, Evidence, Exception and Attestation relationships at the owner's first finalized Git revision. Evidence source-resource links describe context, so current Controls, inventories and ongoing reviews can evolve without rewriting that proof. Source links to Evidence, Exceptions and Attestations still protect those proof records. Mutation guards and historical validation share the same traversal; historical validation resolves the traversal from committed records rather than current links. Evidence packets retain committed source versions alongside current context.

Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "filegrc-monorepo",
"version": "0.21.6",
"version": "0.21.7",
"private": true,
"workspaces": [
"packages/filegrc",
Expand Down
2 changes: 1 addition & 1 deletion packages/create-filegrc/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "create-filegrc",
"version": "0.21.6",
"version": "0.21.7",
"description": "Create a filegrc workspace for a SOC 2 program",
"license": "MIT",
"repository": {
Expand Down
4 changes: 2 additions & 2 deletions packages/create-filegrc/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) {
async function writeMinimalLockfile(target, name, versionRange) {
const lock = {
name,
version: "0.21.6",
version: "0.21.7",
lockfileVersion: 3,
requires: true,
packages: {
"": {
name,
version: "0.21.6",
version: "0.21.7",
dependencies: { filegrc: versionRange }
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/create-filegrc/template/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "{{project_name}}",
"version": "0.21.6",
"version": "0.21.7",
"private": true,
"description": "filegrc workspace for a SOC 2 program",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/filegrc/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "filegrc",
"version": "0.21.6",
"version": "0.21.7",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
Expand Down
14 changes: 11 additions & 3 deletions packages/filegrc/src/git.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const repositorySnapshotPromises = new Map();
const repositoryObjectFormats = new Map();
const gitCommandCaches = new AsyncLocalStorage();
const gitCommandDeadlines = new AsyncLocalStorage();
const gitCommandTimeoutLimits = new AsyncLocalStorage();
const gitCommandAdapters = new AsyncLocalStorage();
const gitCommandCacheBytes = new WeakMap();
let gitCommandInterceptor = null;
Expand Down Expand Up @@ -80,10 +81,16 @@ export function withGitCommandCache(cache, callback) {
return gitCommandCaches.run(cache, callback);
}

export function withGitCommandDeadline(deadlineAt, callback) {
export function withGitCommandDeadline(deadlineAt, callback, options = {}) {
if (!Number.isFinite(deadlineAt))
throw new TypeError("The Git command deadline must be finite.");
return gitCommandDeadlines.run(deadlineAt, callback);
const limit = options.commandTimeoutMs;
if (limit === undefined) return gitCommandDeadlines.run(deadlineAt, callback);
if (!Number.isFinite(limit) || limit <= 0)
throw new TypeError("The individual Git command timeout must be positive and finite.");
const inherited = gitCommandTimeoutLimits.getStore();
return gitCommandTimeoutLimits.run(Math.min(limit, inherited ?? limit), () =>
gitCommandDeadlines.run(deadlineAt, callback));
}

function gitEnvironment(overrides = {}) {
Expand Down Expand Up @@ -4081,6 +4088,7 @@ function runGitCommandNative(cwd, args, options = {}) {
cwd,
args: [...args],
options: { ...options },
timeoutMs,
});
return new Promise((resolveCommand, rejectCommand) => {
const child = spawn("git", args, {
Expand Down Expand Up @@ -4344,7 +4352,7 @@ function boundedGitTimeout(configured, fallback) {
}

function gitTimeoutPlan(configured, fallback) {
const requested = Math.max(1, Number(configured) || fallback);
const requested = Math.min(Math.max(1, Number(configured) || fallback), gitCommandTimeoutLimits.getStore() ?? Infinity);
const deadlineAt = gitCommandDeadlines.getStore();
if (deadlineAt === undefined)
return { timeoutMs: requested, deadlineLimited: false };
Expand Down
91 changes: 76 additions & 15 deletions packages/filegrc/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,11 @@ const STATE_SESSION_MAX_AGE_MS = 5 * 60_000;
const MAX_STATE_SESSIONS = 8;
const MAX_STATE_SESSION_PROMISES = 64;
const RESOURCE_DETAIL_GIT_DEADLINE_MS = 10_000;
const STATE_SECTION_GIT_DEADLINE_MS = 10_000;
// A whole cold calculation can legitimately exceed a single Git command's
// timeout. Keep Git's per-command protection, but bound the complete read too.
const STATE_CALCULATION_DEADLINE_MS = 120_000;
const STATE_RESPONSE_WAIT_MS = 1_000;
const STATE_PENDING = Symbol("pending state calculation");

export function createFilegrcServer(input = process.cwd(), options = {}) {
assertWorkspaceEngineVersion(input, { allowModelDiagnostics: true });
Expand Down Expand Up @@ -169,14 +173,14 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
if (!sameOriginBrowserRead(request)) {
return json(response, 403, { error: "Cross-origin state requests are not allowed." });
}
const deadlineAt = performance.now() + STATE_SECTION_GIT_DEADLINE_MS;
const deadlineAt = performance.now() + RESOURCE_DETAIL_GIT_DEADLINE_MS;
for (let attempt = 0; attempt < 3; attempt += 1) {
if (activeStateMutations > 0) {
await awaitWithinDeadline(new Promise((resolve) => stateMutationWaiters.push(resolve)), deadlineAt);
}
const generation = stateInvalidationGeneration;
if (!bootstrapSnapshotPromise) {
bootstrapSnapshotPromise = withGitCommandDeadline(deadlineAt, () => stableStateSnapshot(input, {
bootstrapSnapshotPromise = withReadGitDeadline(deadlineAt, () => stableStateSnapshot(input, {
fileDigestCache,
deadlineAt
})).finally(() => {
Expand Down Expand Up @@ -210,6 +214,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
expiresAt: Date.now() + STATE_SESSION_MAX_AGE_MS,
revoked: false,
promises: new Map(),
reads: new Map(),
verificationPromise: null,
calculationCache: verifiedGitCache,
gitCommandCache: verifiedGitCache.commands
Expand Down Expand Up @@ -243,14 +248,18 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
if (!session) return json(response, 409, { error: "The workspace state expired. Reload it and try again." });
const sectionDeadlineMs = Number.isFinite(options.stateSectionDeadlineMs)
? Math.max(0, options.stateSectionDeadlineMs)
: STATE_SECTION_GIT_DEADLINE_MS;
: STATE_CALCULATION_DEADLINE_MS;
const deadlineAt = performance.now() + sectionDeadlineMs;
const calculate = () => withGitCommandDeadline(deadlineAt, () => (
const calculate = () => withReadGitDeadline(deadlineAt, () => (
loadStateSessionSection(session, section, options, url.searchParams.get("programId") || undefined, null, deadlineAt)
));
const measured = timingEnabled() ? await collectTimings(calculate) : null;
const state = measured ? measured.result : await calculate();
if (measured) console.error(`[filegrc timing] ${JSON.stringify({ operation: "state-section", section, ...measured.timings })}`);
const measuredCalculate = timingEnabled() ? async () => {
const measured = await collectTimings(calculate);
console.error(`[filegrc timing] ${JSON.stringify({ operation: "state-section", section, ...measured.timings })}`);
return measured.result;
} : calculate;
const state = await respondToStateRead(session, `section:${section}:${requestOptions.programId || ""}`, request, options, measuredCalculate);
if (state === STATE_PENDING) return pendingStateResponse(response, token);
assertCurrentStateSession(session);
return json(response, 200, { stateToken: token, section, state });
}
Expand Down Expand Up @@ -780,14 +789,18 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
const historyOnly = url.searchParams.get("history") === "only";
if (historyOnly) {
const history = session
? await loadStateSessionResourceHistory(session, token, type, id, options)
? await respondToStateRead(session, `history:${type}:${id}`, request, options,
() => loadStateSessionResourceHistory(session, token, type, id, options))
: await createResourceHistory(input, type, id);
if (history === STATE_PENDING) return pendingStateResponse(response, token);
if (!history) return json(response, 404, { error: "Resource not found." });
return json(response, 200, history);
}
const entry = session
? await loadStateSessionResource(session, token, type, id, options, requestOptions.programId, includeWorkflow, url.searchParams.get("history") !== "false")
? await respondToStateRead(session, `detail:${type}:${id}:${requestOptions.programId || ""}:${includeWorkflow}:${url.searchParams.get("history") !== "false"}`, request, options,
() => loadStateSessionResource(session, token, type, id, options, requestOptions.programId, includeWorkflow, url.searchParams.get("history") !== "false"))
: await createResourceDetail(input, type, id, { includeHistory: url.searchParams.get("history") !== "false" });
if (entry === STATE_PENDING) return pendingStateResponse(response, token);
if (!entry) return json(response, 404, { error: "Resource not found." });
if (includeWorkflow && !session) {
const loaded = await loadWorkspace(input);
Expand Down Expand Up @@ -922,7 +935,8 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
backgroundPushDelayMs: options.backgroundPushDelayMs,
stateSectionDeadlineMs: options.stateSectionDeadlineMs,
resourceDetailDeadlineMs: options.resourceDetailDeadlineMs
resourceDetailDeadlineMs: options.resourceDetailDeadlineMs,
stateResponseWaitMs: options.stateResponseWaitMs
});
let usedFallbackPort = false;
try {
Expand Down Expand Up @@ -1121,9 +1135,9 @@ async function loadStateSessionResource(session, token, type, id, serverOptions,
assertCurrentStateSession(session);
const detailDeadlineMs = Number.isFinite(serverOptions.resourceDetailDeadlineMs)
? Math.max(0, serverOptions.resourceDetailDeadlineMs)
: RESOURCE_DETAIL_GIT_DEADLINE_MS;
: STATE_CALCULATION_DEADLINE_MS;
const deadlineAt = performance.now() + detailDeadlineMs;
return withGitCommandDeadline(deadlineAt, async () => {
return withReadGitDeadline(deadlineAt, async () => {
const detail = await withGitCommandCache(session.gitCommandCache, () => createResourceDetail(session.loaded, type, id, {
historyDeadlineAt: deadlineAt,
includeHistory
Expand All @@ -1144,9 +1158,9 @@ async function loadStateSessionResourceHistory(session, token, type, id, serverO
assertCurrentStateSession(session);
const detailDeadlineMs = Number.isFinite(serverOptions.resourceDetailDeadlineMs)
? Math.max(0, serverOptions.resourceDetailDeadlineMs)
: RESOURCE_DETAIL_GIT_DEADLINE_MS;
: STATE_CALCULATION_DEADLINE_MS;
const deadlineAt = performance.now() + detailDeadlineMs;
return withGitCommandDeadline(deadlineAt, async () => {
return withReadGitDeadline(deadlineAt, async () => {
const history = await withGitCommandCache(session.gitCommandCache, () => createResourceHistory(session.loaded, type, id, {
historyDeadlineAt: deadlineAt
}));
Expand All @@ -1156,6 +1170,53 @@ async function loadStateSessionResourceHistory(session, token, type, id, serverO
});
}

function withReadGitDeadline(deadlineAt, task) {
return withGitCommandDeadline(deadlineAt, task, { commandTimeoutMs: RESOURCE_DETAIL_GIT_DEADLINE_MS });
}

// Only opt-in browser reads use pending responses. Other API clients retain the
// original await-until-complete contract. A pending response is never valid data.
async function respondToStateRead(session, key, request, options, calculate) {
if (!prefersFastMutation(request)) return calculate();
assertCurrentStateSession(session);
let job = session.reads.get(key);
if (!job) {
if (session.reads.size >= MAX_STATE_SESSION_PROMISES) {
const completed = [...session.reads].find(([, value]) => value.completed)?.[0];
if (completed === undefined) throw stateSessionCapacityError();
session.reads.delete(completed);
}
job = { completed: false, promise: null };
// Retain failures until a caller receives them, then allow an explicit retry.
job.promise = Promise.resolve().then(calculate).finally(() => { job.completed = true; });
job.promise.catch(() => {});
session.reads.set(key, job);
}
const waitMs = Number.isFinite(options.stateResponseWaitMs)
? Math.max(0, options.stateResponseWaitMs) : STATE_RESPONSE_WAIT_MS;
let timer;
let result;
try {
result = await Promise.race([
job.promise,
new Promise((resolve) => { timer = setTimeout(() => resolve(STATE_PENDING), waitMs); })
]);
if (result === STATE_PENDING) { assertCurrentStateSession(session); return result; }
// A completed read may have waited for its next poll. Verify its snapshot
// again before delivering it; no stale result may enable browser writes.
await verifyStateSessionSnapshot(session, performance.now(), performance.now() + RESOURCE_DETAIL_GIT_DEADLINE_MS);
return result;
} finally {
clearTimeout(timer);
if (job.completed && session.reads.get(key) === job) session.reads.delete(key);
}
}

function pendingStateResponse(response, token) {
response.setHeader("Retry-After", "1");
return json(response, 202, { stateToken: token, status: "pending" });
}

function prefersFastMutation(request) {
return String(request.headers.prefer || "")
.split(",")
Expand Down
2 changes: 1 addition & 1 deletion packages/filegrc/src/state-calculator-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ parentPort.on("message", async ({ id, snapshotId, loaded, section, options, dead
if (section !== "validation") return createAppStateSection(snapshot.loaded, section, options);
const { ok, counts, diagnostics, reconciliation } = await validateWorkspace(snapshot.loaded);
return { ok, counts, diagnostics, reconciliation };
})));
})), { commandTimeoutMs: 10_000 });
parentPort.postMessage({ id, result });
} catch (error) {
parentPort.postMessage({ id, error: { message: error.message, code: error.code, source: error.source } });
Expand Down
Loading
Loading