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
14 changes: 14 additions & 0 deletions .claude/hooks/format-touched.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/bin/sh
# PostToolUse hook: whitespace-format the .cs file the agent just touched.
# Cosmetic and fast; NEVER blocks (always exits 0) — the hard gate is stop-gate.sh.
command -v python3 >/dev/null 2>&1 || exit 0
cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null || exit 0
# dotnet format silently ignores ABSOLUTE --include paths — relativize or bust.
FILE=$(python3 -c 'import json,os,sys; p=json.load(sys.stdin).get("tool_input",{}).get("file_path",""); print(os.path.relpath(p) if p else "")' 2>/dev/null) || exit 0
case "$FILE" in
*.cs) ;;
*) exit 0 ;;
esac
[ -f "$FILE" ] || exit 0
dotnet format whitespace --include "$FILE" --no-restore >/dev/null 2>&1
exit 0
61 changes: 61 additions & 0 deletions .claude/hooks/stop-gate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/bin/sh
# Stop hook, MAINTAINER shape (goldpath delivery-cycle RFC, D4), adapted for Mockifyr.
#
# The application-shaped gate builds the whole app and runs specdrift against its manifest.
# Neither fits here: this repository has no manifest — it is the accelerator, not a generated
# app — and a full 23-package solution build on every turn end is slow enough that the hook
# would be deleted. A deleted gate is worse than an absent one, because it is evidence the
# discipline does not work.
#
# So this gate does the two things that are FAST and that catch what actually goes wrong here:
# it builds only the projects whose files changed, and it runs the repository's own ledger and
# documentation gates, which are the checks a change here most often invalidates.
INPUT=$(cat)

case "$INPUT" in
*'"stop_hook_active":true'* | *'"stop_hook_active": true'*) exit 0 ;;
esac

cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
command -v git >/dev/null 2>&1 || exit 0
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0

CHANGED=$(git status --porcelain -- '*.cs' '*.csproj' '*.props' '*.md' '*.json' '*.yaml' '*.yml' '*.sh' 2>/dev/null)
[ -z "$CHANGED" ] && exit 0

LOG=$(mktemp)

# 1. Build only what changed. A touched .cs belongs to the nearest project above it; building
# that project is seconds, where the solution is minutes.
PROJECTS=$(git status --porcelain -- '*.cs' '*.csproj' '*.props' 2>/dev/null | awk '{print $NF}' | while read -r f; do
d=$(dirname "$f")
while [ "$d" != "." ] && [ "$d" != "/" ]; do
p=$(ls "$d"/*.csproj 2>/dev/null | head -1)
[ -n "$p" ] && { echo "$p"; break; }
d=$(dirname "$d")
done
done | sort -u)

for p in $PROJECTS; do
if ! dotnet build "$p" --nologo -v quiet >"$LOG" 2>&1; then
echo "stop-gate: $p does not build — fix it before ending the turn." >&2
tail -n 30 "$LOG" >&2
rm -f "$LOG"
exit 2
fi
done

# 2. The repository's own gates, in place of the drift check an app would run. These are the
# ones a change here invalidates most often, and they take about a second each.
for gate in docs-guard kit-freshness; do
[ -x "scripts/$gate.sh" ] || continue
if ! "scripts/$gate.sh" >"$LOG" 2>&1; then
echo "stop-gate: scripts/$gate.sh is red — the ledgers or the docs stopped telling the truth." >&2
tail -n 20 "$LOG" >&2
rm -f "$LOG"
exit 2
fi
done

rm -f "$LOG"
exit 0
27 changes: 27 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "sh \"$CLAUDE_PROJECT_DIR/.claude/hooks/format-touched.sh\"",
"timeout": 60
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "sh \"$CLAUDE_PROJECT_DIR/.claude/hooks/stop-gate.sh\"",
"timeout": 300
}
]
}
]
}
}
68 changes: 68 additions & 0 deletions .claude/skills/mockifyr-change/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
name: mockifyr-change
description: Run a change to Mockifyr through its delivery cycle — a roadmap item, a defect, a parity gap. Enforces the SEQUENCE and adds the three steps the repo's own loop does not have; every rule stays in CLAUDE.md and docs/testing.md.
---

# mockifyr-change — the sequence, not a second rule book

This repository already has a development loop (`CLAUDE.md` §3), a testing contract (§3a and
`docs/testing.md`) and a documentation contract (§3b). **They are the rules. This skill does
not restate them** — a skill that repeats a rule becomes a second source of truth waiting to
disagree with the first.

What follows is the SEQUENCE, plus the three steps the loop does not have. Everything else is
a pointer.

## Read first

1. `CLAUDE.md` §3 (the loop), §3a (tests), §3b (docs) — the rules
2. `docs/parity/` for the area you are touching — this is the repository's MEMORY; a surprise
that is already recorded there costs you nothing to learn twice
3. `docs/decisions/` — if the behaviour you are about to add looks missing, check whether its
absence was deliberate

## The sequence

**1 — What kind of change is this?**

*A roadmap item:* `CLAUDE.md` §3 owns it, step by step. Follow it.

*A defect:* the loop is roadmap-shaped and does not cover this, so: **prove the cause before
touching code.** Evidence, not a hypothesis — the failing request, the diff against the oracle,
the actual bytes. If two explanations fit, rule one out in writing. A defect fixed from the
first plausible explanation is a defect that comes back wearing different clothes.

**2 — The failing test, and PROOF that it fails**

§3 already says the test comes first and must fail. The half it does not say: **put the fault
back afterwards and confirm the test goes red again.** A test that is green on both sides of a
fix advertises a guarantee it does not provide. With a differential harness this is cheap —
revert the implementation, re-run, watch the diff reappear.

**3 — Implement minimally, then §3a in full**

Layers, mutation on new pure logic, the edge-case checklist, all suites green. `docs/testing.md`
is the contract; do not summarise it here.

**4 — Feed the memory**

§3's step 5. `docs/parity/` is where a surprise becomes a durable fact, and the register in
`deferred-edges.md` is where a gap becomes visible instead of forgotten.

**5 — Watch what you did not intend to change**

The loop does not ask this and it should: does an existing test encode the behaviour you just
changed — and is that test now asserting the old contract? Move it deliberately and say so.
Did a field's meaning change while its name did not? Does removing a path leave a stale flag,
screen or doc page behind? §3b lists the documents; this step is the judgement §3b cannot make.

**6 — §3b, then stop for approval**

Documentation in the same branch, including the website repo when the change is user-facing.
Then the summary and the stop, exactly as §3 ends.

## The hook

`.claude/hooks/stop-gate.sh` will not let a turn end on a red build or a red repository gate.
It builds only the projects whose files changed, because a full solution build on every turn
end is slow enough that the hook gets deleted — and a deleted gate is worse than an absent one.
20 changes: 20 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,26 @@ WireMock**, never by self-assessment.

---

## 2a. The agent layer (`.claude/`)

Added 2026-09-08 from the family's delivery-cycle RFC (goldpath `docs/rfc/goldpath-delivery-cycle-v1.md`).
It does NOT introduce a second rule book: §3, §3a and §3b below stay the rules, and
`.claude/skills/mockifyr-change` enforces their SEQUENCE while adding the three steps the loop
here does not have —

- **prove the cause before touching code** when the change is a defect rather than a roadmap
item (§3 is roadmap-shaped and has no defect path),
- **prove the test fails** by putting the fault back after the fix, not only before it, and
- **watch the as-is**: is an existing test now asserting the old contract, did a field's
meaning change while its name did not.

`.claude/hooks/stop-gate.sh` will not let a turn end on a red build or a red `docs-guard` /
`kit-freshness`. It builds only the projects whose files changed, deliberately: a full solution
build on every turn end is slow enough that the hook would be deleted, and a deleted gate is
worse than an absent one.

---

## 3. The development loop (per roadmap item)

Every item in [docs/roadmap.md](docs/roadmap.md) is developed the same way:
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,28 @@ PVC, Ingress and OpenShift Route, with credentials and crypto keys injected from
helm install mockifyr deploy/helm/mockifyr --set persistence.enabled=true
```

**With persistence on, prove the volume is writable rather than assuming it.** The image makes `/work`
group-writable so any UID in group 0 can use it, but a PVC mounted there *replaces* that directory
with a freshly provisioned one — `root:root 0755`, which group 0 can only read. OpenShift normally
closes the gap itself: `restricted-v2` injects an `fsGroup` from the namespace's range and the kubelet
chowns the volume to match. It usually just works, and it costs one command to know instead of
finding out later from a write that fails:

```
oc rsh deploy/mockifyr touch /work/probe && echo WRITABLE
```

If it does not, the chart has no `fsGroup` of its own to fall back on — read the namespace's allowed
range and pass a value from inside it:

```
oc get ns <namespace> \
-o jsonpath='{.metadata.annotations.openshift\.io/sa\.scc\.supplemental-groups}'

helm upgrade mockifyr deploy/helm/mockifyr \
--set persistence.enabled=true --set podSecurityContext.fsGroup=<first-number-of-that-range>
```

### Backup and restore

`GET /__admin/backup` produces one archive of everything a tenant's operator authored — stubs,
Expand Down
16 changes: 16 additions & 0 deletions docs/parity/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ Decisions worth remembering:
Configuration cannot be unit-tested like the engine, but a template edit that weakens a default now
fails the build. `helm lint` and `kubeconform` schema validation run alongside it.

**A PVC undoes the image's own answer to arbitrary UIDs, and the chart does not set `fsGroup`.**
The Dockerfile makes `/work` group-writable (`chmod -R g=u`) precisely so any UID in group 0 can use
it — which is what the non-root section above buys. Mounting a PVC there *replaces* that directory
with a freshly provisioned one, `root:root 0755`, and group 0 can only read it. Reproduced
deliberately outside Kubernetes to be sure it was the ownership and not the application: an empty
volume chowned to `root:root 0755`, entered as an arbitrary high UID with GID 0, refuses `touch` with
`Permission denied`; give the same volume a group the process carries and it writes, the host starts,
and stubs survive replacing the container.

In practice OpenShift closes this itself — `restricted-v2` injects an `fsGroup` from the namespace's
`openshift.io/sa.scc.supplemental-groups` range and the kubelet chowns the volume to match — which is
why the chart carries none and why nothing has failed. The gap is that "usually" is doing real work in
that sentence: on a cluster whose SCC does not inject one, the pod runs, the probes pass, and the
first write fails. Setting `podSecurityContext.fsGroup` from that range removes the guess, and the
README now says so beside the `helm install` line rather than leaving it to be discovered.

**Deferred:** HPA guidance. The ServiceMonitor shipped with the metrics endpoint (#246), and the
NetworkPolicy and PodDisruptionBudget shipped in #397 — see below.

Expand Down
Loading