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
51 changes: 51 additions & 0 deletions .github/workflows/why-anchor.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: why-anchor

# Post-merge re-anchoring (docs/ci.md, DESIGN.md §4). Anchors are re-stamped
# only from main, never from a branch: `as_of` records the commit a span was
# verified at, and a squash merge rewrites branch commits into one new commit,
# so an as_of stamped on a branch names something that never reaches main. Run
# from main, every as_of is a commit main keeps.
#
# The result is PR'd back rather than pushed, so branch protection stays on and
# the frontmatter-only diff is reviewable. The as_of values inside the files
# name main commits, so squashing *this* PR does not orphan them.
on:
push:
branches: [main]

# One at a time: two merges in quick succession would otherwise race to write
# the same branch.
concurrency:
group: why-anchor
cancel-in-progress: false

jobs:
anchor:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0 # anchor resolution traces line ranges from as_of to HEAD
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run why -- anchor
- uses: peter-evans/create-pull-request@v6
with:
branch: why-anchors
add-paths: .why
title: "why: re-anchor from main"
commit-message: "why anchor: re-stamp from ${{ github.sha }}"
body: |
Mechanical re-anchoring from `main` (`${{ github.sha }}`).

`why anchor` rewrites only `why.anchors` entries — never narrative —
so this diff is frontmatter-only. Anchors are stamped here rather
than on the contributing branch because a squash merge discards
branch commits, and an `as_of` must name a commit main keeps.
4 changes: 3 additions & 1 deletion .github/workflows/why-capture.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ on:

jobs:
capture:
if: github.event.pull_request.head.ref != 'why-drafts'
# `why`'s own bot PRs carry no rationale to capture — drafting from them
# would recurse and record the machinery instead of a decision.
if: github.event.pull_request.head.ref != 'why-drafts' && github.event.pull_request.head.ref != 'why-anchors'
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
12 changes: 9 additions & 3 deletions .github/workflows/why-pr-gate.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
name: why-pr-gate

# PR gate (docs/ci.md): the archive may not merge in a state it cannot back.
# `why lint` enforces the schema; `why anchor --check` fails on anchor drift
# without writing anything — run `why anchor` locally and commit the result.
# `why lint` enforces the schema. `why anchor --check --allow-drift` fails only
# on an anchor this PR *destroyed* — code it claimed is gone, which no
# re-anchoring can recover and which the author has to resolve.
#
# Drift (a span that merely moved) is deliberately not a failure: fixing it here
# would mean running `why anchor` on this branch, stamping an `as_of` that the
# squash merge then discards (DESIGN.md §4). The why-anchor job re-stamps drift
# from main after the merge, where the commit survives.
on:
pull_request:

Expand All @@ -19,4 +25,4 @@ jobs:
cache: npm
- run: npm ci
- run: npm run why -- lint
- run: npm run why -- anchor --check
- run: npm run why -- anchor --check --allow-drift
42 changes: 26 additions & 16 deletions AUTOBUILD.md → .sandcastle/README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
# AUTOBUILD — the zero-human pipeline
# The Sandcastle pipeline

How `why` gets built from concept to working software with no human gate. The
division of labor:
An optional issue→PR pipeline: labelled GitHub issues become implemented,
reviewed, merged PRs, with an agent gatekeeper standing in for the human merge
gate. It built the initial `why` codebase (phases 1–4, issues #1–#22) and is
kept here for bulk work that suits it — a batch of well-specified,
independent tasks. Day-to-day development does not use it; see
[CONTRIBUTING.md](../CONTRIBUTING.md) for the normal workflow.

Nothing in the shipped product depends on this directory. It is repo tooling.

| Role | Who | Where |
|---|---|---|
| Product/architecture | Claude (chat sessions with Dan) | README.md, DESIGN.md, HOWTO.md |
| Issue authoring | Claude | [issues/](issues/) — filed by the bootstrap script |
| Product/architecture | Chat sessions | README.md, DESIGN.md, HOWTO.md |
| Issue authoring | Chat sessions | GitHub issues, labelled `Sandcastle` |
| Planning, implementation, internal review, PR assembly | Sandcastle agents (Docker-sandboxed) | `@copperbox/sandcastle-workflow` |
| **Final review, remediation, merge, escalation** | **Gatekeeper agent** | [.sandcastle/gatekeeper.mts](.sandcastle/gatekeeper.mts) |
| **Final review, remediation, merge, escalation** | **Gatekeeper agent** | [gatekeeper.mts](gatekeeper.mts) |
| Phase sequencing | Gatekeeper (promotion) | `phase:N` labels |

## The loop

`npm run sandcastle:auto` ([scripts/autonomous-loop.sh](scripts/autonomous-loop.sh)) alternates two runs forever:
`npm run sandcastle:auto` ([scripts/autonomous-loop.sh](../scripts/autonomous-loop.sh)) alternates two runs forever:

```
┌──────────────────────────────────────────────────────────┐
Expand Down Expand Up @@ -42,9 +48,9 @@ division of labor:
→ nothing left anywhere → exit 4: DONE
```

Phase 1 issues are filed pre-queued; phases 2–4 sit as labeled backlog until
everything before them has merged, so the dependency order across phases is
enforced by the ratchet, not by hope.
Issues carrying a `phase:N` label sit as backlog until everything in earlier
phases has merged, so dependency order across a batch is enforced by the
ratchet rather than by hope. A batch with no `phase:N` labels just drains.

## Design decisions (and their whys — this file eats its own dog food)

Expand Down Expand Up @@ -96,8 +102,8 @@ enforced by the ratchet, not by hope.
issues), invariants that are checkable rather than aspirational
(CODING_STANDARDS.md), a verify the agent can't overrule, and a
merge-is-expensive prompt stance. It is still one model family grading its
own homework; the Phase 2 torture test and Phase 3 hand-grading exist
precisely to audit the gate's judgment after the fact.
own homework — which is why the pipeline is reserved for well-specified
batch work and not the default path for changes to this repo.
- **Cost.** Every issue is multiple agent runs (plan, implement, review,
merge, gate, possibly fix rounds). The per-phase ratchet caps the blast
radius of a bad stretch; `GATE_MAX_ROUNDS` caps per-PR spend.
Expand All @@ -110,17 +116,21 @@ enforced by the ratchet, not by hope.
- **Host-side gate agents.** Build agents run in Docker; gate agents run on
the host with tool allowlists (read/git-read/npm for review; +edit/commit
for fix, push done by the script). Tighter than the build side's sandbox?
No — narrower tools, weaker walls. Acceptable for a private repo of our own
generated code; revisit before pointing this at anything public.
No — narrower tools, weaker walls. **This was scoped to a private repo of
our own generated code. The repo is now public, so issue text is
attacker-controllable input that reaches a host-side agent: never point the
loop at issues you did not write.** Contributor PRs go through the normal
human review in [CONTRIBUTING.md](../CONTRIBUTING.md), never through this
gate.

## Runbook

```bash
# once
cp .sandcastle/.env.example .sandcastle/.env # fill in both tokens
scripts/bootstrap-github.sh # repo + labels + issue backlog + image
npx sandcastle docker build-image # build agents' sandbox image

# the whole build
# per batch: file the issues, label them "Sandcastle", then
npm run sandcastle:auto

# pieces, when wanted
Expand Down
14 changes: 7 additions & 7 deletions .sandcastle/config.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// Sandcastle configuration for the `why` autonomous build.
// See AUTOBUILD.md for how this composes with the gatekeeper into a
// zero-human-gate pipeline.
// Sandcastle configuration for the optional agent pipeline.
// See README.md in this directory for how this composes with the gatekeeper.

import { defineConfig } from "@copperbox/sandcastle-workflow";

Expand All @@ -11,8 +10,9 @@ export default defineConfig({
// npm-shaped repo: version-bump each feature PR.
release: { enabled: true },

// Private repo, single trusted owner; the defaults are fine but explicit
// here because the gatekeeper relies on its own comments being trusted.
// Explicit because the gatekeeper relies on its own comments being trusted.
// These were chosen when the repo was private with a single trusted owner;
// the repo is now public, so only ever queue issues written by a maintainer.
security: { trustedCommentsOnly: true, lockOnQueue: false },

// The gatekeeper does its own remediation (it cannot route feedback through
Expand All @@ -23,8 +23,8 @@ export default defineConfig({

implementNotes: [
"DESIGN.md at the repo root is the source of truth for the `why` schema and",
"architecture; issues/ carries the per-task specs. Read the DESIGN.md sections an",
"issue cites before writing code. examples/harbor/ is the fixture bundle —",
"architecture; the issue body carries the per-task spec. Read the DESIGN.md",
"sections an issue cites before writing code. examples/harbor/ is the fixture bundle —",
"tests should run against it rather than inventing new fixtures. Never let a",
"code path emit a silently-wrong anchor, and never assert rationale above its",
"evidence — these two rules override convenience every time.",
Expand Down
49 changes: 49 additions & 0 deletions .why/decisions/anchors-are-written-from-main.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
type: decision
title: Anchors are written from main by CI, so the PR gate stops failing on drift
description: A why-anchor job re-stamps anchors on every push to main, and the PR
gate runs --allow-drift, failing only on an anchor the change destroyed.
tags:
- anchoring
- ci
timestamp: 2026-07-15
why:
status: active
happened_on: 2026-07-15
confidence: recorded
anchors:
- path: .github/workflows/why-anchor.yml
as_of: 0f75578
state: live
- path: .github/workflows/why-pr-gate.yml
as_of: 0f75578
state: live
- path: docs/ci.md
as_of: 0f75578
state: live
---

`why anchor` runs in CI on every push to `main` and PRs the frontmatter-only result back on the `why-anchors` branch. Contributors do not run it on their branches, and the PR gate no longer asks them to: it runs `why anchor --check --allow-drift`, which reports drift and exits 1 only on an anchor the change *destroyed*.

# Why

The gate caused the bug it was meant to prevent. `why-pr-gate.yml` failed on any anchor drift, and its own comment said "run `why anchor` locally and commit the result" — but a contributor's HEAD is exactly what a squash merge discards. Following the instruction stamped an `as_of` that named nothing a day later. Both orphans in this bundle were created that way: `518bf47` by [1] (a `why anchor` run on `why-drafts`), and `44d4118` by [2]. Every `as_of` CI has ever written is a clean ancestor, because `why capture` already reads the merge commit from `main` — the one component doing this correctly was proof the approach works before it was generalized.

So the gate was demanding the one thing that cannot be done correctly from a branch. Ancestry gating and the verified merge-base stamp ([as_of must be an ancestor](/decisions/as-of-must-be-an-ancestor.md)) contain the damage, but they cannot prevent it: when a branch *is* what changed a span — or when a file is *born* on the branch, as `.sandcastle/README.md` was — no surviving commit holds that span, so the only truthful stamp is a HEAD the squash then throws away. Running from `main` dissolves that case rather than mitigating it: the merge commit both exists and contains the code.

Squashing the `why-anchors` PR does not re-orphan anything, which is the crux and the natural objection. A squash rewrites *commits*; the `as_of` **values** inside the files name `main` commits and survive the squash as ordinary content.

# Costs

Accepted deliberately, because the alternative is a gate that teaches people to corrupt the archive:

- **The bundle becomes eventually consistent.** Between a merge and the re-anchor PR landing, `main`'s anchors can be stale. They degrade honestly (`lost` or `unverified`), never to a wrong span, so §2's promise holds — but `why blame` can be briefly out of date, and the anchor update no longer rides in the same review as the code change.
- **The gate is weaker.** Drift merges. A destroyed anchor still blocks, which keeps the part worth keeping: you cannot delete the code a concept describes without being told.
- **It is a convention until enforced.** Nothing stops a human running `why anchor` on a branch. The stamping rule limits the blast radius, and `CLAUDE.md` and `CONTRIBUTING.md` say not to.

Rejected: keeping the strict gate (it cannot be satisfied honestly from a branch); pushing straight to `main` from CI (works, and is defensible for a frontmatter-only diff, but breaks branch protection for adopters copying the recipe).

# Citations

[1] [4f7d259 — the `why anchor` run on why-drafts that stamped an orphaned as_of](https://github.com/copperbox/why/commit/4f7d2594da7110c4de539406389733756194866f)
[2] [7391fd7 — the same mechanism recurring on release-prep](https://github.com/copperbox/why/commit/7391fd70d006f779dba69a4121780f7ef826c78d)
17 changes: 14 additions & 3 deletions .why/decisions/as-of-is-provenance.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
type: decision
title: as_of is provenance, so doctor does not flag clean-ancestor anchors
description: A live anchor whose as_of is a clean ancestor of HEAD is stable, not stale; why doctor only flags unresolved or diverged as_ofs.
tags: [anchoring, doctor]
timestamp: 2026-07-13
description: A live anchor whose as_of is a clean ancestor of HEAD is stable,
not stale; why doctor only flags unresolved or diverged as_ofs.
tags: [ anchoring, doctor ]
timestamp: 2026-07-15
why:
status: active
happened_on: 2026-07-13
Expand All @@ -21,6 +22,16 @@ a commit unrelated to HEAD (diverged/rebased history) or one the repository
cannot resolve at all. A live anchor whose `as_of` is a clean ancestor of HEAD
is left alone — it is healthy provenance, not a problem.

**Scope, amended 2026-07-15:** the rule protects a *readable* `as_of` — a
clean ancestor, which is evidence the span survived unchanged since that
commit. An `as_of` that is a non-ancestor (or does not resolve) carries no
such meaning, and "never re-stamp a stable anchor" does not extend to it:
`why anchor` repairs such an orphan when the claim re-verifies at HEAD without
reading `as_of` — see
[orphaned as_of is repaired](/decisions/orphaned-as-of-is-repaired.md). That
repair is what makes the `not-ancestor` finding this decision kept genuinely
actionable.

# Why

`why anchor` and `why doctor` were built against inconsistent readings of what
Expand Down
Loading
Loading