Design & engineering · 2026

the decisions core keeps honest

A team decision log where an accepted record is immutable, every transition is audited, and a citation knows when the code it governs has moved.

  • Next.js
  • Postgres
  • Drizzle
  • better-auth
  • Versioning
  • Mermaid
  • Vitest

What core is

core is a team decision log. A team records an architecture decision, its reasoning, and the code it applies to. The record moves through a lifecycle: proposed, then accepted, then deprecated or superseded. It is the one project here outside design tooling, aimed at workflow, permissions and audit trails.

Notion holds the document without the governance: an agreed decision can be rewritten and leaves no trace. Jira holds the workflow without the document: a ticket carries a state machine and an audit log, and no place for several paragraphs of reasoning. core carries both, and adds a reference to the code.

A record cites the code it governs by line range and reports whether that code has changed since the decision was accepted. The rest of the architecture is built around keeping that report accurate.

The domain is two engines under lib/, neither of which knows what a database is. lib/versioning is append-only document history, domain-agnostic. lib/decisions is the decision log. Both depend on a store interface rather than on Drizzle, so each has an in-memory implementation the tests run against and a Postgres one that ships. Reasoning is in core’s DESIGN.md.

fetchessnapshotscommitsserver actionsNext.js App Router, one per intentlib/githubtrees · contents · blob SHAslib/decisionslifecycle · drift · citationslib/versioningsnapshot · diff · restorememory-storewhat the 16 test files run againstdrizzle-storePostgres, one transaction per writestore interfacenothing above this line knows what a database issame contract

Drift detection

A decision cites the code it governs. That code changes after the decision is filed, so the document carries a status reporting whether the cited code still matches what was agreed. Three outcomes: in sync, changed, missing.

A citation names a line range, not a file. {{owner/repo:path#L47-L120}} claims the decision governs those lines. A whole-file reference reports drift on any commit touching the file, and the false positive rate scales with file size: an edit anywhere in a 900-line module marks every decision citing it as stale.

Line numbers alone are not enough. Insert twenty lines above a cited block and the block is unchanged but sits at 67 to 140. core stores the cited text alongside the range. When the range no longer matches, that text is searched for elsewhere in the file before the reference is called drifted, and a block found intact has its range updated.

lib/rate-limit.tsL6-L14in sync

3 lines above

4const LIMIT = 100
5
6export async function take(key: string) {
7 const now = Date.now()
8 const bucket = Math.floor(now / WINDOW_MS)
9 const slot = `rl:${key}:${bucket}`
10
11 const used = await redis.incr(slot)
12 if (used === 1) await redis.pexpire(slot, WINDOW_MS)
13 return { ok: used <= LIMIT, used }
14}

same lines, same place

// core/lib/decisions/snippet.ts
export function compareSnippet({ baseline, content, range }): SnippetVerdict {
  const wanted = normalize(baseline)

  // The overwhelming majority of checks find the code where it was left.
  if (normalize(extractRange(content, range)) === wanted) return { status: "synced" }

  // Not there. Before calling it drift, look for it elsewhere in the file.
  const moved = locate(content, baseline)
  if (moved) return { status: "moved", range: moved }

  return { status: "changed" }
}

The same-place check runs first. It is a string comparison over two extracted ranges and answers most calls, so the scan over the file only runs after it fails.

normalize strips trailing whitespace and a trailing newline, so a formatter run does not mark decisions stale. Leading indentation is kept: a block whose indentation changed has changed scope.

// core/lib/decisions/snippet.ts
// Trailing whitespace and a final newline are not semantic changes, and treating
// them as drift would fire on a formatter run. Leading indentation *is* kept: a
// block that changed indentation moved scope, which is a real change.
function normalize(text: string): string {
  return text
    .split("\n")
    .map((line) => line.replace(/\s+$/, ""))
    .join("\n")
    .replace(/\n+$/, "")
}

The baseline is set at acceptance, not at authoring. rebaselineReferences moves every reference’s baseline to the code as it stands when the decision is accepted. Without it, a proposal that sat in review for a fortnight reports as drifted as soon as it is agreed. A block that moved during review is followed rather than re-pinned; re-pinning the old line numbers would point the citation at whatever occupies them now.

Re-baselining is best-effort and cannot fail the acceptance, which is an audited transition that has already been recorded. The service holds no GitHub token: the caller fetches the file contents and passes snapshots in, so lib/decisions makes no network calls.

Drift is reported on the document, not while writing or editing. A file cited moments ago is in sync by construction.

Reaching the code

Everything in the section above depends on an author actually citing the right lines, which is a front-end problem before it is a domain one. A workspace connects GitHub repositories, and the picker searches across every one of them at once. Choosing a repository first was a gate in front of the only step that mattered: an author citing a file knows the filename far more often than they know which repo it is in.

Trees are cached five minutes per instance. The picker asks for every connected repo on every mount.

the repo is a label on the row, not a filter you chose first.

Connecting a repository still needs the person’s own account. Listing “your repositories” through the deployment’s token would show the owner’s repositories to whoever happened to be signed in. Repository trees are cached for five minutes per instance, because a tree is a few hundred KB, changes rarely, and the picker asks for every connected repo on every mount.

Every way that picker can fail is something a person can act on: not a member, GitHub off, no account linked, no repositories connected, GitHub unreachable. So the server action returns each as words rather than throwing. React reports a rejected server action as error #441, with the message stripped out of the production build, and anything catching that and showing the text displays React’s apology as though it were an explanation. That cost two rounds of fixing the wrong throw to learn.

A citation is plain text. {{owner/repo:path#L47-L120}} is a form the author can type, paste and edit, and it survives being copied into a commit message or a chat thread, which a rich-editor node would not. Choosing a file inserts one with a click, so nobody types it by hand, but what is stored is still the token. Tokens are rewritten into ordinary markdown links before parsing, so the renderer needs no plugin and inherits the escaping react-markdown has already hardened. An unresolvable token renders as inline code rather than vanishing, so a typo is visible instead of silently swallowed.

no. —draftworkspaceVault
BI#{}
Decision
Adopt Postgres, with **Drizzle** for migrations.

The limiter this governs is
{{hipuku/core:lib/rate-limit.ts#L47-L120}}.

```mermaid
flowchart LR
  cite[cite a range] --> accept[accept]
  accept --> pin[baseline pinned]
  pin --> check{still matches?}
```
one sheet, two tabs. no editor node ever exists — the token is what is stored.

The editor underneath is a plain <textarea>. No CodeMirror, no contenteditable, nothing to keep in sync with how the document later renders. What makes it feel like markdown is behaviour while typing: Enter continues a list and ends it on an empty item, 3. becomes 4., Tab indents across every line the selection touches, ⌘B ⌘I ⌘K ⌘E wrap and unwrap, and ⌘↵ submits from anywhere in the document. All of it is text in and text out, tested directly rather than through the DOM. The toolbar inserts the same syntax in front of you, so it teaches the shortcut it stands in for.

What renders is GitHub-flavoured markdown plus Mermaid diagrams in ```mermaid fences, drawn client-side with securityLevel: strict.

The lifecycle table

Five statuses. TRANSITIONS is four rows and holds every legal move with the capability it requires. Nothing changes a decision’s status except by matching a row. rejected, deprecated and superseded are terminal because no row starts from them.

acceptrejectdeprecatesupersedeproposedrejectedaccepteddeprecatedsuperseded
VAU-014proposed

checkTransition(proposed, to, author)

  • acceptedrequires the accept capability
  • rejectedrequires the reject capability
  • deprecatedno transition from proposed to deprecated
  • supersededno transition from proposed to superseded
  • edit bodyok

2 of 4 rows start from proposed

the refusal strings are the ones core ships.
// core/lib/decisions/lifecycle.ts
export const TRANSITIONS: readonly TransitionRule[] = [
  { from: "proposed", to: "accepted",   capability: "accept" },
  { from: "proposed", to: "rejected",   capability: "reject" },
  { from: "accepted", to: "deprecated", capability: "deprecate" },
  { from: "accepted", to: "superseded", capability: "supersede" },
]

export const ROLE_CAPABILITIES: Record<Role, Capability[]> = {
  author:     ["propose", "edit"],
  maintainer: ["propose", "edit", "accept", "reject", "deprecate", "supersede"],
}

Roles are capability bundles. An author may propose and edit. A maintainer may also accept, reject, deprecate and supersede. An author sees no lifecycle actions rather than disabled ones.

Guards return { ok: false, reason } rather than throwing or returning a boolean, so an interface that does show a refused action can print why it is unavailable.

// core/lib/decisions/lifecycle.ts
export function checkTransition(from, to, actor): Guard {
  const rule = TRANSITIONS.find((r) => r.from === from && r.to === to)
  if (!rule) {
    return { ok: false, reason: `no transition from ${from} to ${to}` }
  }
  if (!actor.capabilities.includes(rule.capability)) {
    return { ok: false, reason: `requires the ${rule.capability} capability` }
  }
  return { ok: true }
}

Content is editable only while proposed. canEditContent refuses with a decision is immutable once it leaves ‘proposed’. checkSupersede requires both decisions to be accepted, refuses self-supersession, and requires the supersede capability.

supersededById is a single column, so the relationship is visible one hop at a time. lineage.ts walks it in both directions from any decision and returns the chain oldest first. A decision in no chain returns an empty array rather than a chain of one. One seen set covers both walks: a cycle is reachable in both directions, so two separate guards would each stop correctly and still collect the same decision twice.

Two audit trails

Content revisions and status transitions are stored separately. Revisions live in the versioning engine. Transitions live in an append-only decision_transitions log carrying actor, time and an optional reason.

content trailstatus trailworkspaceskeyowner_iddecisionsnumberstatusdocument_idsuperseded_by_iddecision_draftsno numberauthor_idbodydecision_referencesstart_line / end_linebaseline_snippetbaseline_shacurrent_shadocument_versionsparent_idstate (jsonb)append-onlydecision_transitionsfrom_statusto_statusactor_idreason

The versioning engine is domain-agnostic. A commit is an immutable snapshot of the whole state plus a parent pointer. The diff between two versions is computed rather than stored: a structural JSON diff over RFC 6901 pointers, objects by key and arrays by index.

restore writes a new commit whose state equals the target rather than moving the head back. History stays append-only, the restore appears in it as its own event, and two people editing one document cannot erase each other’s history. git revert, not git reset.

VAU-014proposedAdopt Postgres for the decision log
Activity·0 revisions
    the restore is itself a commit.

    Both trails sit behind one Activity drawer, which carries a summary while closed: last activity 2 days ago, 3 events.

    Design decisions

    Storage is a port. Both engines depend on a store interface rather than on Drizzle. memory-store and drizzle-store implement the same contract, so the tests exercise the domain rather than mocks. Two writes must not tear: a decision and its opening transition, a status change and its audit row. Each is a single method on the interface, so the Drizzle implementation wraps each in one transaction.

    // core/lib/decisions/store.ts — the interface the domain depends on
    /** Change a decision’s status and append its transition atomically. */
    applyStatusChange(input: {
      decisionId: string
      toStatus: DecisionStatus
      updatedAt: Date
      transition: TransitionRecord
    }): Promise<void>
    
    // core/lib/decisions/drizzle-store.ts — one implementation of it
    await db.transaction(async (tx) => {
      await tx.update(decisions).set({ status: input.toStatus, ... })
      await tx.insert(transitions).values(input.transition)
    })

    A draft is not a lifecycle status. The alternative was a sixth status. A draft holds no ADR number, because reserving one leaves a gap in the sequence whenever a draft is abandoned, and numbers are how decisions are cited. It is private to its author, where every status is workspace-visible. It has no transitions. Drafts are a separate table, and draft edits are not versioned.

    The editor is a plain textarea. CodeMirror and contenteditable were both rejected. What makes an editor feel like markdown is behaviour while typing: lists that continue themselves and end on an empty item, Tab that indents across every line the selection touches, and wrapping shortcuts that unwrap when already wrapped. All of it is text in and text out, tested directly rather than through the DOM, and it leaves no third-party editor to keep in sync with how the document later renders. The citation token is plain text for the same reason: it survives being pasted into a commit message.

    One surface is paper. The decision page had grown five cards across three widths, two grounds and two elevations, and nothing said which surface mattered. Now one white sheet holds the document, and properties, notices and tabs sit on the desk around it. Every region shares the sheet’s measure, so the page has two vertical edges rather than six. The dossier card is the exception that proves it: status, owner and dates are the current state of a decision’s history, so the audit trail expands inside the card that summarises it rather than beside it.

    Functions run in Sydney. Deployed on Vercel against a Neon database in Sydney, with the functions pinned to syd1. A signed-in page load makes several queries in sequence, session then membership then the data, and with the compute in Virginia every one of them crossed the Pacific. Co-locating them was the largest single change to how the deployed app performs against the local one.

    core depends on no local package. Not haus, not kern. It is the portfolio’s range slot, and coupling it to a library under active development would work against that. It has its own tokens and its own CSS, which is what the demos here are wearing.

    What is not done

    Array diffing is index-based rather than a longest-common-subsequence match. Inserting an item at the front of an array reports every following index as changed. The output is correct but not minimal.

    Citing a file in prose does not start tracking it for drift. A file cited as a counter-example would otherwise report drift against a decision that never governed it. The gap is visible: a chip in the text with no entry in the reference list reads as an inconsistency. The likely fix is a track-this-file control on an untracked citation rather than tracking it silently.

    The deployed demo runs the same code with three flags set. Sign-up is absent rather than gated, because an invite code is a shared secret rather than access control. The seeded account may write and save drafts, which are private and hold no number. It may not accept, reject, deprecate or supersede. Fourteen actions refuse and two do not, enforced at the action layer.

    GitHub linking is off on that deployment. The app requests the repo scope, which is read and write on private repositories, and better-auth stores those tokens in the account table. File browsing runs instead through GITHUB_PUBLIC_TOKEN, a read-only public-repositories token belonging to the deployment, used when the signed-in person has no linked account. Repository trees are cached for five minutes per instance, because the picker requests every connected repo on every mount.

    Email verification is off and there is no explicit rate limiting. Both are prerequisites for opening sign-up. Drafts are bounded meanwhile at 128KB each and 20 per author per workspace, since saveDraft is reachable by anyone with a session. The full walkthrough is in FEATURES.md.