Three iron rules
Everything below rests on these three. Break one and the rest of the machinery is decoration.
Worktree, never the live tree
You build in a worktree, on a branch. The deployed tree is read-only, and a detector enforces it: scripts/check-deployed-tree-pristine.mjs. Shallow boundary, moved HEAD, a changed tracked file, main drift: it goes red. The host never breaks "mysteriously".
Deploy is a human act
main reaches the host only through a human running ./scripts/orgctl deploy. orgctl hard-refuses up/restart/deploy/down under an agent identity (DSH_ORG_AGENT). A restart without a human is a finding, not a feature.
No direct pushes to main
Branch protection requires the CI gate. The owner keeps an admin bypass: a bypass is a named act, an incident with a register entry. It is never a habit, and a hotfix is never an implementation.
The cycle in 8 stages (+ deploy)
Two surfaces, one split (D70). The queue (comm/) is exchange: handoffs between seats, only through comm_* tools. Kanban is durable objects: project, epics, stories, the branch's card. State lives in kanban; conversation lives in the queue. Never the other way round.
diagram 1 · the cycle end to end
flowchart TD
REQ["1. Request - anyone
queue or card: the PROBLEM, not the fix"] --> ASSESS["2. control - assess
go / no-go + a one-paragraph reason"]
ASSESS -->|"no-go → back"| REQ
ASSESS -->|"go"| GOAL["3. product - business goal
goal + scope + acceptance criteria
project / epics / stories (kanban)"]
GOAL --> SPLIT["4. pmo - split
tasks sized to one implementer
ordering + dependencies"]
SPLIT --> IMPL["5. Implement - department
worktree: branch dept/card-short-id
NEVER the live tree"]
IMPL --> QA["6. Test - technology (QA)
checks green ON THE BRANCH + qa-report.yml
+ task report dshorg.task-report/v1 (D73)"]
QA --> REV["7. Review - quality-review + control
+ the seat for the path class (security/legal)"]
REV -->|"findings → stage 5"| IMPL
REV --> PR["8. PR to main
gate: verify + gitleaks + verdicts"]
PR --> MERGE["Merge - agent, when CI is green
gh pr merge --auto"]
MERGE --> DEP["9. Deploy - HUMAN ONLY
./scripts/orgctl deploy"]
style MERGE fill:#1a2332,stroke:#ff6600,color:#e8eaed
style DEP fill:#ff6600,stroke:#ff4500,color:#0f1824
Diagrams render with mermaid.js from a CDN - without a network you see the diagram sources.
Request - anyone
The queue or a card names the PROBLEM. Not the fix: the fix is downstream, and the person who names it owns the argument.
Assess -
controlGo or no-go, one paragraph, in writing. A no-go returns to the requestor with the reason attached.
Business goal -
productGoal, scope, acceptance criteria. More than one task? Then project, epics, stories. If the criteria are fuzzy here, every later stage pays for it.
Split -
pmoTasks sized to one implementer, ordered, dependencies named. A task two people could "share" is two tasks.
Implement - department
git worktree add repos/.worktrees/<card-short-id> -b <department>/<card-short-id>. One task, one branch. The branch name carries the department (D74, enforced in CI bycheck-branch-name.mjs): everyone commits under one git identity, so the branch name is the only attribution that exists.Test -
technology(QA)Touched suites green on the branch. New behavior ships with its own check in both directions: pass AND refuse. A gate that only tests the happy path is an observation, not a gate. The ACs go to
qa-report.yml; the stage ends with adshorg.task-report/v1report (D73).Review -
quality-review+control+ the seat for the path classVerdicts per criterion, against the validated task report. Findings go back to stage 5. "Looks good to me" is not a verdict; data is.
PR -
technologygh pr create, thechecks.ymlgate, then an agent merges when CI is green (gh pr merge --auto). The deploy stays human. That is stage 9, and no stage skips it.
Worktrees and carrying work
- repos/.worktrees/<card-short-id>/
- The default home of trees. Gitignored, inside the workspace (the sandbox clips writes outside
/opt/dsh-org), survives a reboot. Your uncommitted work outlives your session here. - /tmp/dsh-wt-<card-short-id>
- Legal scratch. Dies with the reboot, and so does whatever you left in it.
~/worktrees/<id>only underdanger-full-access: an escalation, not a habit. - Uncommitted work
- Does not survive a turn boundary. Commit WIP to YOUR branch, every time. Never stash into the live tree.
- The D27 carrier
scripts/carry-uncommitted.mjs(technology) moves builders' uncommitted work onto a branch: one unit commit, explicit path list, authors named, baseline re-sealed. A path nobody owns is refused, on purpose.- Attribution
- One git identity for everyone. The branch name is the only place attribution lives, hence D74:
<department-id>/<card-short-id>, department fromdepartments/*.yaml.
QA as a machine: the proof comes from a run, not from the implementer
A person claiming "tests pass" is a rumor with confidence. A run URL on the card is evidence. So the evidence is produced by the runner, not typed by the implementer.
diagram 2 · qa-report.yml - no model calls between the check and the evidence
sequenceDiagram
participant QA as QA (technology)
participant G as GitHub Actions (runner dsh-org)
participant C as Named checks (bash+node)
participant K as Kanban (Postgres)
QA->>G: dispatch qa-report.yml - card_id + checks AC1=cmd, AC2=cmd
G->>C: runs EVERY named check
C-->>G: rc + output tail per criterion
G->>G: deterministic report - ZERO model calls
G->>K: addNote(card, report, author=ci-runner)
Note over K: the run URL in the note = evidence
the implementer could not have typed
- Dispatch
gh workflow run qa-report.yml -f card_id=<id> -f checks="AC1=<cmd>,AC2=<cmd>". The runner executes every named check and itself posts the report onto the card asci-runner. No model call sits between the check and the evidence.- A plugin's tool surface
- A card touching a plugin's
lib/orsrc/must namenode scripts/check-tool-manifests.mjs. A tool changing shape under the same name is a supply-chain event, and the manifest check is how you see it happen. - Staging tree
node scripts/staging.mjs up|status|env|down: a worktree onorigin/main. The verify loop picks the tree throughDSH_ORG_CHECK_ROOT(unset = deployed,staging= staging, otherwise a path). Every recorded run keeps repo_root/commit/dirty, so "which tree did you measure" always has an answer.- Task report (D73)
- One JSON
dshorg.task-report/v1on the card: summary, criteria AC1..ACn (pass|fail|deferred + evidence ref),machineQa.runUrl, which must appear in aci-runnernote, residualRisks, notDone, selfVerdict.validate-task-report.mjschecks it in code;check-task-reports.mjssweeps every card inreview. A report the validator cannot parse is not a report. - Order: machine QA is a POST-MERGE confirmation (card 5041a7b0)
- The workflow takes no ref: it always measures the default branch. Dispatch before the merge and you measure main before the change (measured on card eba0172c: two false FAILs, expensive lesson). The order: PR, verdicts, merge, dispatch, the
ci-runnerreport, task report, close. - The checks-map contract: word-split, zero quoting (card 4e3ee456)
- The runner executes a command by word splitting (
$cmdwithout a shell, on purpose). Double quotes are literal characters:grep -c "pattern" filesearches for the pattern including the quotes, a false FAIL. A pattern with a space splits the command. Write map commands without quotes:grep -c pattern docs/file.md.
Review: verdicts are data (card a3c8f952)
The whole org runs on one GitHub identity (wizardeur). GitHub refuses self-approval, so classic required approvals is mechanically impossible here. So we stopped asking reviewers for opinions and started demanding verdicts:
- A verdict = a comment carrying JSON
dshorg.pr-verdict/v1:{seat, verdict: "approve", head_sha}.head_shamust be the PR's current head: an approval of an older commit is not an approval of this one. That detail has caught more sloppy reviews than any policy memo would.- A mechanism, not a sentence
scripts/check-review-verdict.mjsin theverifyjob refuses a PR without approve from the required seats naming the current head. A probe that cannot look ends rc 2 (could not look): an honest "I don't know", never a false certificate.- Path classes pull the seats
scripts/review-verdict-paths.json: data, not code (the gate reads the BASE version; the file names itself).security-reviewfor vault/secrets, scanners, CI (.github/workflows/*),infra/**,acts/**, guard-floor/ops tools.legal-risk-reviewforLICENSE,Content/**. Touch the class, pull the seat.- Why verify
- The gate rides the
verifycheck, a known gating check. A new job is not in the required-check list, and the token gets a 403 reading the protection config. Ride what already gates. - Who may post a verdict: the seat Apps (card 5c81afe9)
- Every seat has its own GitHub App (
dsh-org-<seat>[bot], vaultgithub/app/<seat>/id+key).scripts/lib/seat-identity.mjsmints a fresh installation token per run, never the owner's token. The gate matches the seat to the comment's author login (authorMatchesSeat), not the text: a verdict is who posted it, not what it claims. Verdicts from before the Apps read only inside a named, dated window (LEGACY_SELF_ASSERTED, expires 2026-11-01). - The Apps only POST
events: []on all four (measured 2026-10-11). Nothing arrives through the Apps, so nothing can sneak in either. The Apps' descriptions are edited by the owner in the web UI: the REST API has no update-an-App endpoint, a fun fact that cost an afternoon to confirm.
GitHub automation (`.github/workflows/`)
| Workflow | Trigger | What it does |
|---|---|---|
checks.yml | pull_request (+ catch-ups issue_comment / pull_request_review) | The PR gate: the verify job (hermetic checks: syntax sweep, us-english, shared-protocols, canonical-lists, generate+task-routing, deps-pinned, branch name D74, review verdicts) + blocking gitleaks; the full job = the whole check-all.sh on the box |
qa-report.yml | workflow_dispatch (card_id + the AC→check map) | Machine QA - the report lands on the card (author ci-runner) |
security-sast.yml | push to main | blocking gitleaks + informational semgrep (SARIF) |
security-dast.yml | cron: Sunday 03:00 UTC | testssl + nuclei + ZAP passive against the auth gate :8080 |
merge-time-gate.yml | push to main | merge-time measurement; snapshots to the merge-time-gate/snapshots branch, never protected main |
Tool supply chain and the merge metric: two mechanisms on top
- The golden manifest (G11c pointed inward)
scripts/check-tool-manifests.mjs+tool-manifests.golden.json. Every plugin's tool-definition surface (entry point + every lib/src module holding tool registrations) is hashed into a committed golden. Change a tool's name, description or input schema and the hash moves: check-all fails BY NAME until someone deliberately re-freezes with--freeze --reason "...". No reason, no freeze; the thawed golden lands in the same commit. The pre-commit hook runs the check in--stagedmode, so the refusal happens at your commit, not later in CI where you have already moved on. Each plugin carries its own append-onlyhistory: only a plugin whose hash moved earns an entry, so the trail stays a trail and not a second golden. Context: the 2025-26 supply-chain series (MCPTox, ShareLock, Deadbugz). A name allowlist answers the wrong question, because the attack lives in a tool's prose.- The merge-time gate and the check over silence (card f4454166)
merge-time-gate.ymlrecords one row per merge to main underpresets/control/register/merge-time-gate/(snapshots to themerge-time-gate/snapshotsbranch, never protected main). This mechanism's natural defect is silence: a missing row looks exactly like a measurement that found nothing. Twelve runs went by before control went looking.check-merge-time-gate.mjsturns silence into an observable: every merge after the capability commit (located hermetically from the workflow's history) must carry a LIVE row (merge_commit = that merge, backfill ≠ true). The rollout lesson (card a5ff0513): apermissionsblock zeroes every unlisted scope, so the runner recorded an rc 2 as if it were a measurement, and pushing the row to protected main was refused with GH006. The writer trusts no token and no branch it was not handed.
diagram 3 · the CI pipeline - from trigger to deploy
flowchart LR
subgraph T["Triggers"]
PR["pull_request"] --> VERIFY["verify - the hermetic core
+ branch name (D74)
+ review verdicts (a3c8f952)"]
PR --> GITLEAKS["gitleaks
secrets - blocking"]
CMT["issue_comment /
pull_request_review"] --> VERDICT["verdict job
(catch-up after a comment)"]
PUSH["push to main"] --> SAST["security-sast
blocking gitleaks + semgrep info"]
PUSH --> MTG["merge-time-gate
merge-time snapshot"]
CRON["cron: Sunday 03:00 UTC"] --> DAST["security-dast
testssl + nuclei + ZAP"]
DISP["workflow_dispatch
card_id + checks"] --> QAR["qa-report"]
end
VERIFY --> M["merge - agent, when green
gh pr merge --auto"]
GITLEAKS --> M
M --> DEP["deploy - human
orgctl deploy"]
style M fill:#1a2332,stroke:#ff6600,color:#e8eaed
style DEP fill:#ff6600,stroke:#ff4500,color:#0f1824
Runner infrastructure
- 5× self-hosted runner (label
dsh-org) - systemd, user
gh-runner, host rx7900xtx.lhst.net. Parallelism = the runner count. We ran one runner for months and verify started 10 minutes after a push; nobody noticed because nobody measured. Now it is measured. - Joining
infra/ci-runner-add.sh. A human with admin mints the registration token: 1 h validity, never stored. A runner counts as OK only when its own log says Listening for Jobs, not when the script exits zero.- Toolchain
- Per-step: setup-node 22 + pnpm pinned at 12.8.1. No step depends on the account's PATH, because an account is not a contract.
- Branch protection
- The
verifycheck is required onmain. The owner keeps an admin bypass: an incident is a named act. An agent merges--autowhen green; the deploy and every gate bypass are human.
Engineering rules - docs/engineering-rules/ (17 files)
Check culture
CI and local run the same check-all.sh. They cannot drift apart, because a gate that behaves differently locally than in CI is two gates and you only wrote one. The PR gate is the hermetic core; the full suite runs on the box at QA. run_known2 absorbs only rc 2 (NOT OBSERVED, a documented limitation); rc 1 is always FAILURE. Suspending a red is a named act, never a silent one. Generated artifacts (MAP.generated.md, ACTS.generated.md) must be fresh: the generate check; check-generate.mjs also pins the ban on reaching the queue from outside the comm_* tools.
The schema declares the key, the profile supplies the value
A plugin declares its zod/schemastery Config in its source. The deployment supplies values through a profile patch (cord.patch.yml). There is no third place, and "just this once" is the third place.
docs/DEVELOPMENT.md.Which skill catches which stage
| Stage / situation | Skills |
|---|---|
| The whole org-code cycle | org-development |
| Stages 1-3: problem, options, architecture | solution-exploration, solution-architecture |
| Stage 4: split, ACs, a plan for a separate session | implementation-planning |
| Stage 5: branch / worktree / merge | branch-and-worktree-workflow |
| Stages 5-6: red-green, pinning both directions | test-driven-development |
| Diagnosis before a fix | systematic-debugging |
| Stage 7: review and responding to feedback | code-review |
| Before "done" (verification instead of declaration) | completion-verification |
| Agent fan-out without write collisions | parallel-agent-delivery |
| Interfaces / operability / release / debt | api-design, observability-and-reliability, release-and-deployment, technical-debt-management |
| Queue, config, runtime, decisions across departments | org-communication, org-config, org-ops, org-brainstorm / org-wonder |
| Continuity across sessions / keeping skills honest | handoff, skill-authoring |
Where the guarantees stop
The limits are written down. Nobody should rediscover them in production.
card_check is the live tree: a merged, approved card can stay red until the human deploy (measured on card eba0172c, and it confused everyone for an hour). card_check runs record their tree (repo_root/commit/dirty) and the judge re-runs in the recorded tree. Since card f8e750e5 an explicit root may also be a tree from the repos/ plane: same origin remote, path inside repos/registry.json. Outside the plane or with a different origin: refused, reason named.
infra/webhooks/README.md: HMAC X-Hub-Signature-256, vault github/webhook_secret, the dsh-webhook-tunnel.service fallback). Status: designed, not deployed (2026-10-10: 503 on the profile). The seat Apps only POST (events: []); the Apps' descriptions are owner-UI only. Nothing arrives that the map did not invite.