@forgrit/shared

v0.2.0

Published

Cross-pillar shared infrastructure for ForGrit's three repos (Software Foundry, Software Orchestrator, Workforce): a hash-chained evidence ledger and resilience primitives (retry, circuit breaker, rate limiter).

Readme

@forgrit/shared

Cross-pillar shared infrastructure for ForGrit's three repos (Software Foundry, Software Orchestrator, Workforce): a hash-chained evidence ledger and resilience primitives (retry, circuit breaker, rate limiter).

What does NOT belong here

This package is scoped, cross-repo infrastructure only — not a dumping ground. Explicitly excluded (see docs/superpowers/plans/2026-08-17-software-foundry-rename-and-shared-package.md's Global Constraints for the full reasoning):

  • Software Orchestrator's actual orchestration intelligence (mission-control, kernel, planner, execution, brain, repository-* modules)
  • Software Foundry's product engine (blueprint, codegen-types, debugger, design-*, shared/contracts-prompt)
  • Workforce's employee-governance model (Principal, RoleCard, authorize())
  • The Repository Intelligence "Frozen Five" contract (Snapshot/Finding/Metric/Relationship/Insight) — gated behind its own stage-promotion rule, not ready yet
  • software-orchestrator/modules/orchestrator/connector-runtime/RetryPolicy.ts or RateLimiter.ts as source material to copy from (both have known compile-error bugs; this package's resilience module uses their design ideas, not their code)

Purpose

This package is not a dumping ground for utility code. It hosts only infrastructure types and primitives that:

  • Are consumed by more than one sibling repo (Foundry, Orchestrator, or Workforce)
  • Carry cross-repo invariants (e.g., evidence ledger record structure)
  • Do not belong in any single pillar's codebase

All new exports require evidence that the need appears in at least two consumers.

Subpath Exports

@forgrit/shared/evidence

A hash-chained ledger for recording system decisions and side effects. Every record stores a SHA-256 hash over its own fields plus its predecessor's hash, so editing any historical record breaks the chain from that point onward and verifyChain reports exactly where.

What this gives you, and what it does not

Be precise about the security property, because the shape of the guarantee decides where the ledger is worth deploying:

  • The hashes are unkeyed. Nothing here is signed. There is no HMAC, no key, no signature — only SHA-256 over a canonical serialization.
  • The records are not immutable. They are plain JavaScript objects. readonly appears on array parameters and constrains this package's own functions; it does not freeze anything a caller holds.
  • It detects partial edits. An actor who rewrites one stored record — or a few — and leaves the rest alone is caught: the recomputed hash no longer matches, and every subsequent prevHash link is wrong.
  • It does not stop a full rewrite. An actor who can rewrite the whole stored ledger can recompute every hash forward from the edit and produce a chain that verifies cleanly. There is nothing in an unkeyed chain that such a writer cannot reproduce.

So this is tamper-evident against narrow tampering and accidental corruption, not a signature scheme. Its real value comes from pairing it with an append-only storage layer that restricts who can rewrite history: the storage layer makes wholesale rewriting hard, and the chain makes anything less than a wholesale rewrite visible. On its own, it is a corruption detector.

Invariants

  • seq is 0-indexed. The first event in a chain is seq: 0 with prevHash: GENESIS_HASH; each later event has seq === previous.seq + 1 and prevHash === previous.hash.
  • The hash preimage is an explicit, versioned field projectionv, seq, prevHash, type, subjectId, actorId, payload, observedAt — never a spread of whatever the caller passed. Extra fields on a stored record (a row id, an ORM's own columns) are outside the chain's protection and cannot perturb a recomputed hash. HASH_VERSION is baked into the preimage so a future change to that projection is distinguishable rather than silently incompatible.
  • payload must be JSON-safe. appendEvent throws InvalidEvidenceEventError on values whose canonical form would be lossy — Date, Map, Set, RegExp, Error, class instances, NaN/Infinity, bigint. Convert first: date.toISOString(), Object.fromEntries(map), { name, message } for an error. This is deliberate: a Date used to serialize as {}, so two records with timestamps decades apart hashed identically.
import { appendEvent, verifyChain, type EvidenceEvent } from '@forgrit/shared/evidence';

let chain: EvidenceEvent[] = [];

// A null head starts a chain: seq 0, prevHash = GENESIS_HASH.
chain = [...chain, appendEvent(null, {
  type: 'mission.completed', subjectId: 'mission-1', actorId: 'employee-1',
  payload: { outcome: 'success' }, observedAt: new Date().toISOString(),
})];

// Afterwards, pass the head — just `{ seq, hash }`, not the whole history, so a
// database-backed caller appends after reading a single row. An EvidenceEvent
// satisfies that shape, so the previous event can be handed over directly.
chain = [...chain, appendEvent(chain[chain.length - 1], {
  type: 'mission.archived', subjectId: 'mission-1', actorId: 'employee-1',
  payload: { reason: 'completed' }, observedAt: new Date().toISOString(),
})];

verifyChain(chain); // { valid: true, length: 2 }

Verifying a page out of the middle of a long ledger, rather than loading all of it:

verifyChain(pageOfRows, {
  expectedFirstSeq: 1000,
  expectedFirstPrevHash: lastRowOfPreviousPage.hash,
});

With no options, verifyChain anchors at seq: 0 / GENESIS_HASH and verifies a whole chain.

@forgrit/shared/resilience

Resilience primitives for distributed systems: retry with exponential backoff, a circuit breaker, and a token-bucket rate limiter.

import { withRetry, CircuitBreaker, isTransientError } from '@forgrit/shared/resilience';

const breaker = new CircuitBreaker({ name: 'external-api', failureThreshold: 5 });

async function callApi(): Promise<Response> {
  return breaker.execute(() =>
    withRetry(() => fetch('https://api.example.com'), { maxAttempts: 3 }),
  );
}

Retry classification. withRetry retries only what isTransientError accepts: ECONNREFUSED, ECONNRESET, ETIMEDOUT, ENOTFOUND, EAI_AGAIN. It reads a structured error.code first and falls back to the message text, but only where a code sits in machine-written position — so new Error('user typed ETIMEDOUT into the form') is not treated as a network timeout. It also walks the cause chain (and AggregateError.errors), which is what makes the fetch example above work: Node throws TypeError: fetch failed and puts the real network error in cause, where a message-only check would never find it.

ENOMEM and ENOSPC are deliberately not retried — memory and disk exhaustion do not clear because a caller tried again, and retrying adds load to a host already out of the resource.

Supply isRetryable to classify errors yourself. It replaces the built-in check rather than extending it; call isTransientError inside your predicate to keep both:

await withRetry(fn, {
  isRetryable: (error) => isTransientError(error) || isRateLimited(error),
});

Circuit breaker probe limit. While HALF_OPEN, the breaker admits at most halfOpenMaxProbes calls at a time (default: halfOpenSuccessThreshold), and canExecute() returns false beyond that until the circuit transitions. The count is of probes in flight — admitted and not yet settled through recordSuccess/recordFailure.

Known limitation: a probe that never settles (the caller never reports its outcome — e.g. it hangs with no timeout of its own) holds its slot permanently. If enough probes hang to reach halfOpenMaxProbes, the circuit is wedged in HALF_OPEN indefinitely, with no time-based recovery — only an explicit reset() clears it. Give every probe you drive through the breaker its own timeout so it always settles.

Installation

npm install @forgrit/shared

Usage

Each subpath is independently importable:

// Import from evidence subpath
import { /* ... */ } from '@forgrit/shared/evidence';

// Import from resilience subpath
import { /* ... */ } from '@forgrit/shared/resilience';

License

MIT — see LICENSE.