core ties decision records to code
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. An accepted record is immutable, and every move it makes is recorded with who made it and when.
A record cites the code it governs by line range and reports whether that code has changed since the decision was accepted.
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.
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. A file reference is synced, drifted or missing. A link, or a file reference with no baseline, is unknown and shows no status.
A citation names a line range. {{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.
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.
3 lines above
same lines, same place
lib/rate-limit.tsL6-L14// core/lib/decisions/snippet.ts
export function compareSnippet({ baseline, content, range }): SnippetVerdict {
const wanted = normalize(baseline)
// Most 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 when the decision is accepted. 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, where a reader decides whether to act on it. A file cited moments ago is in sync by construction.
Reaching the code
Everything in the section above depends on an author 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. An author citing a file knows the filename far more often than they know which repo holds it.
Trees are cached five minutes per instance. The picker asks for every connected repo on every mount.
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, so a typo stays visible.
Adopt Postgres, with **Drizzle** for migrations. The limiter this governs is {{acme/api:lib/rate-limit.ts#L47-L120}}. ```mermaid flowchart LR cite[cite a range] --> accept[accept] accept --> pin[baseline pinned] pin --> check{still matches?} ```
The editor underneath is a plain <textarea>. 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 as the shortcuts.
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.
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
// 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 at all.
Guards return { ok: false, reason }, 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’; supersede it instead. 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.
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. The shape git revert uses.
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 domain suite drives real behaviour. 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 a table of its own. 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 one exception: 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 builds on haus. It did not at first, because a library still changing its interface would have moved core’s with it. After haus 1.0.0 published a token contract, core took haus-tokens under its own brand and thirteen haus-components between 2026-09-07 and 2026-09-10. The demos on this page use core’s stylesheet from before that change.
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 sign-up closed, GitHub linking off and one shared account. There is no sign-up page; an invite code was rejected because whoever holds one can pass it on. The shared account may save and discard drafts, which hold no number, and every visitor sees the same drafts: a nightly job deletes those not updated in 24 hours and restores the seeded one. Fifteen actions refuse it at the action layer, including the drift re-check, which records reference state. Three actions only read from GitHub.
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 FEATURE.md.