Skip to content
Open
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
74 changes: 74 additions & 0 deletions .github/workflows/fork-only-patches.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Fork-only patch guard.
#
# Every other workflow in this repo triggers on `main`. But fork-only patches
# are lost on `develop_base` - that is the branch rebuilt onto upstream main,
# and the branch every fork PR targets. Without this workflow the guard would
# only ever run when a human remembered to run `pnpm check`, which is the same
# failure mode it was built to remove.
#
# Deliberately NOT gated by paths-ignore: a rebuild can drop a patch regardless
# of which paths its own diff appears to touch.
#
# This file is itself a registered fork-only patch (see
# scripts/fork-only-patches.json), so a rebuild that drops the guard's CI
# trigger is caught by the local `pnpm check` run.
name: Fork-only patches

on:
push:
branches: [develop_base]
pull_request:
branches: [develop_base]
workflow_dispatch:

concurrency:
group: fork-only-patches-${{ github.ref }}
cancel-in-progress: true

jobs:
guard:
name: Fork-only patch guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 24

# The baseline must be the IMMUTABLE PRE-push commit. Fetching
# `origin/develop_base` here would name the tree we are validating - a
# witness that is the accused - and the old code did exactly that.
# GitHub supplies the previous tip out of band, which the pushed tree
# cannot forge: github.event.before for a push (survives force-push),
# pull_request.base.sha for a PR.
- id: baseline
run: |
case "${{ github.event_name }}" in
push) sha='${{ github.event.before }}' ;;
pull_request) sha='${{ github.event.pull_request.base.sha }}' ;;
*) sha='' ;;
esac
# All-zeros means the branch did not exist before: no prior claim.
if [ -z "$sha" ] || [ "$sha" = "0000000000000000000000000000000000000000" ]; then
echo "sha=" >> "$GITHUB_OUTPUT"
else
# A force-pushed rebuild leaves the old tip unreachable from any ref.
git fetch --no-tags origin "$sha" 2>/dev/null || true
echo "sha=$sha" >> "$GITHUB_OUTPUT"
fi

# No dependency install: the guard uses node builtins only, so it stays
# runnable even when the tree is mid-rebuild and deps are inconsistent.
- run: node --test scripts/check-fork-only-patches.test.mjs
- name: Fork-only patch guard
run: |
# No `||` fallback on purpose: if the baseline was supposed to exist
# and cannot be resolved, the guard must go red, not quietly retry
# with a weaker check.
if [ -n "${{ steps.baseline.outputs.sha }}" ]; then
node scripts/check-fork-only-patches.mjs --baseline-ref "${{ steps.baseline.outputs.sha }}"
else
node scripts/check-fork-only-patches.mjs --no-baseline
fi
196 changes: 196 additions & 0 deletions docs/fork-only-patches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# Fork-only patches

Patches this fork carries that **upstream `zts212653/clowder-ai` main does not**.

Every `develop_base` rebuild onto upstream main starts from a tree without them.
If nobody re-applies them, they vanish silently.

## Truth source

`scripts/fork-only-patches.json` — machine-readable registry.
Enforced by `scripts/check-fork-only-patches.mjs`, wired into `pnpm check`.

This document explains the *why*; the registry is the *what*. Do not duplicate
the file/anchor lists here — they would drift.

## Why a guard instead of a checklist

The F192 evidence-prerequisite gate was lost **three times**:

| When | How | Noticed after |
|---|---|---|
| 2026-08-11 | develop_base rebuild | ~2 days (PR #91 recovery) |
| 2026-08-28 | develop_base rebuild | ~1 day (PR #137 recovery) |
| 2026-09-01 | commit `70d79a2f2` — a commit whose own title was *"recover fork-only development layer lost in develop_base rebuild"* | **21 days** |

Each loss let the scheduler burn one full LLM session per daily eval fire,
re-concluding a gap it could not act on.

The third loss is the decisive one: a rebuild that was *explicitly trying* to
recover fork-only code still missed this patch. Human/agent recall is not a
mechanism. A guard is.

## Upstream boundary

Pushing these patches upstream is **not** the answer, and was already decided:

> Closing — this is fork-specific harness logic that doesn't belong in upstream.
> The rebuild-loss issue should be solved by improving our fork's develop_base
> rebuild SOP (rebase fork-only patches), not by pushing internal code into the
> community repo.
>
> — `zts212653/clowder-ai#1352`, closed 2026-08-25

Upstream review of that PR also surfaced two real defects, both fixed in the
fork version rather than carried forward blindly:

1. The probe read the **scheduler process's** telemetry handle while evidence is
fetched from the **adapter target** (`EVAL_BASE_URL`). These coincide in this
fork's single-process deployment, but only accidentally. The probe now
asserts co-location via `evidenceTargetIsLocal` and fails closed when it
cannot prove it.
2. The skip notice always advised configuring `TELEMETRY_HMAC_SALT`, even when
the detected cause was an intentional `OTEL_SDK_DISABLED=true`. The next-step
text is now derived from the detected cause.

## Why a tree-only check is not enough

A develop_base rebuild is, quoting commit `70d79a2f2`'s own body:

> The develop_base rebuild (b739c279c) reset to origin/main + fork/optimizations
> but dropped all develop_base-only merges - 33 first-parent commits [...]
> 443 files modified by BOTH main and fork: **NOT restored**

Two things follow.

First, the shared-file edits (the `requiredAnchors` below) fall exactly in that
"modified by BOTH / not restored" bucket. The file survives, the fork's edit
inside it does not - so an existence check reads clean while the patch is gone.

Second, and worse: a check that reads its registry **from the tree** cannot
report on a rebuild that replaces the tree. The reset takes the registry and
this script along with the patches, and a guard that is not there does not go
red. That is how three losses stayed silent.

`fork/optimizations` does not solve this either. It is an orphan snapshot - 7
commits, no merge-base with upstream main, a July full-tree copy - so
"is the patch in the carrier" is not a survival predicate, and asking for
current code to be committed onto a stale unrelated history is incoherent.

## What actually survives: an immutable pre-reset commit

`git reset --hard upstream/main` replaces the working tree, so the baseline has
to come from outside it. Two sources qualify, and one that looks like it does
**not**:

| Source | Immutable? | Notes |
|---|---|---|
| `github.event.before` (push) | yes | previous branch tip, supplied by the platform; survives force-push |
| `github.event.pull_request.base.sha` | yes | the PR base |
| local `origin/develop_base` **before** pushing | yes, briefly | still names the prior tip during a manual pre-push run |
| `origin/develop_base` fetched **after** the push | **NO** | it is the tree being validated |

That last row was a real bug in this workflow's first version: CI fetched
`origin/develop_base` after the push, so the baseline *was* the accused. The
checker now refuses to compare a commit with itself **when the baseline was
named explicitly**, instead of reporting a cheerful green.

### Explicit vs default baseline

Who names the baseline decides what "baseline == HEAD" means:

| Caller | baseline == HEAD means | Result |
|---|---|---|
| explicit `--baseline-ref` (CI) | the caller mis-wired the witness | **fail closed** |
| registry default (manual run) | there is no older tree in this checkout | HEAD witnesses the **working tree** |

The default case is the normal state of a clean `develop_base` checkout. Failing
closed there made the first step of canonical `pnpm check` red on the branch the
guard protects, so nothing after it ran. Instead, the committed HEAD is compared
against the working tree: an uncommitted registry or patch reversion is still
caught. What the default path **cannot** see is a loss that was already
committed - the output says so explicitly - and that is exactly why CI passes
the immutable pre-reset commit, and why the registry anchors that wiring in the
workflow: deleting `--baseline-ref` from CI turns the tree check red rather
than quietly falling back to the default.

### The registry is a shared file too

A rebuild can **revert** `scripts/fork-only-patches.json` rather than delete it.
Both trees then look internally consistent, and a guard that only checks the
patches its *current* registry lists will confirm its own amnesia as healthy.
So the survival check compares the **claim sets**: every patch id the baseline
registered must still be registered. A claim that quietly disappeared from the
registry is a loss, not a smaller honest claim.

### Running it

```bash
# Rebuild pipeline: capture the tip BEFORE the destructive reset.
PRE=$(git rev-parse origin/develop_base)
git fetch upstream && git reset --hard upstream/main # ... rebuild ...
node scripts/check-fork-only-patches.mjs --baseline-ref "$PRE"
```

`--no-baseline` skips the survival half and says so in the output. It exists for
the one honest case with no prior tree (branch creation) and must never be used
as a fallback when a baseline lookup failed - a guard that quietly downgrades
itself is the disease, not the cure.

## What this in-tree workflow does NOT close

`.github/workflows/fork-only-patches.yml` cannot survive its own deletion. If a
rebuild drops workflow + guard + registry together, GitHub runs the workflow
**from the pushed commit**, where it no longer exists, so nothing runs and
nothing goes red.

So, honestly scoped:

- **Closed by the workflow**: any rebuild that preserves it - the baseline is the
immutable pre-push SHA, so dropped files, reverted anchors and dropped registry
claims are all named before the result is deployed.
- **NOT closed by the workflow**: a rebuild that deletes the workflow itself.

The authoritative enforcement for that case lives **outside the repository**, in
branch protection, and is not configured by this PR:

> Settings -> Branches -> branch protection rule for `develop_base`
> -> **Require status checks to pass before merging**
> -> required check: **`Fork-only patch guard`**

A required check that never reports **blocks** rather than passes, which is
exactly the fail-closed behaviour the in-tree file cannot provide for its own
absence. This requires repository-admin rights; until it is configured, treat
the workflow as covering the common case only, and do not claim the full-wipe
case is mechanically prevented.

## Rebuild SOP

Run the guard as the **last step of a rebuild, before pushing**:

```bash
git fetch origin develop_base # baseline must be present
pnpm check:fork-only-patches
```

Red output names each dropped file and anchor. Re-apply them, then re-run. Do
not push or deploy a rebuild while this check is red.

CI runs the same guard on every push and PR to `develop_base`
(`.github/workflows/fork-only-patches.yml`) - every other workflow in this repo
triggers on `main` only, which is why losses on develop_base went unseen.

## Adding a patch to the registry

Add an entry to `scripts/fork-only-patches.json` with:

- `id`, `owner`, `why` — what breaks in production if it disappears
- `upstreamStatus` — why it is not upstream (so nobody re-litigates it)
- `requiredFiles` — files that must exist
- `requiredAnchors` — files that must still *contain* given symbols, for patches
that modify shared files rather than adding new ones (these are the ones a
rebuild silently reverts, since the file still exists)
- `order` - call-site strings that must appear in sequence, for patches whose
meaning is positional. Bare substrings are satisfied by an import, an
interface field or a comment; anchor on the call site instead, so a gate
moved after the step it must precede is caught.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
"build": "pnpm -r run build",
"build:app-assist": "node desktop/scripts/build-app-assist.cjs",
"lint": "pnpm -r run lint",
"check:sources": "pnpm check:brand-guard && pnpm check:no-provider-canary-residue && pnpm check:paw-feel-disposition-boundaries && pnpm check:f290-product-copy && pnpm check:f290-workbench-boundary && pnpm check:dir-size && pnpm check:daemon-lifecycle && pnpm check:capability-tips && pnpm check:co-creation-docs-lane && pnpm check:review-completion-routing && pnpm check:skills:references && pnpm check:skills:surfaces && pnpm check:env-registry && pnpm check:env-example && pnpm check:worktree-reaper && pnpm check:node-runtime-guard && pnpm check:prompt-time-apis && pnpm check:followup-tails && pnpm check:scripts-ascii-only && pnpm check:f212-reconciliation-eval && pnpm check:site-css",
"check:sources": "pnpm check:fork-only-patches && pnpm check:brand-guard && pnpm check:no-provider-canary-residue && pnpm check:paw-feel-disposition-boundaries && pnpm check:f290-product-copy && pnpm check:f290-workbench-boundary && pnpm check:dir-size && pnpm check:daemon-lifecycle && pnpm check:capability-tips && pnpm check:co-creation-docs-lane && pnpm check:review-completion-routing && pnpm check:skills:references && pnpm check:skills:surfaces && pnpm check:env-registry && pnpm check:env-example && pnpm check:worktree-reaper && pnpm check:node-runtime-guard && pnpm check:prompt-time-apis && pnpm check:followup-tails && pnpm check:scripts-ascii-only && pnpm check:f212-reconciliation-eval && pnpm check:site-css",
"check:installed": "pnpm check:sidebar-projection-boundary && pnpm biome check . --diagnostic-level=error && pnpm check:biome-review-worktrees && pnpm check:sop-definitions && pnpm check:design-gate-real-interaction && pnpm check:design-md && pnpm check:skills:manifest && pnpm check:env-ports && pnpm check:start-profile-isolation && pnpm check:guides && pnpm check:no-codex-provider-exit-suppression && pnpm check:whisper-dispatch && pnpm check:tts-runtime && pnpm check:site-integrity",
"check:artifacts": "pnpm check:paw-feel-disposition-capacity && pnpm check:mcp-surface-governance && pnpm check:features && pnpm check:eval-metric-glossary:prepared && pnpm check:capability-tips-enablement && pnpm check:pre-merge-gate",
"check": "pnpm check:sources && pnpm check:installed && pnpm check:artifacts",
Expand All @@ -72,6 +72,7 @@
"check:mcp-surface-governance": "pnpm --filter @cat-cafe/mcp-server governance:check",
"check:no-codex-provider-exit-suppression": "node --test --test-concurrency=1 scripts/check-no-codex-provider-exit-suppression-literal.test.mjs scripts/check-no-codex-provider-exit-suppression-ast.test.mjs scripts/check-no-codex-provider-exit-suppression-isolation.test.mjs scripts/check-no-codex-provider-exit-suppression-structural.test.mjs scripts/check-no-provider-canary-residue.test.mjs && node scripts/check-no-codex-provider-exit-suppression.mjs && node scripts/check-no-provider-canary-residue.mjs",
"check:no-provider-canary-residue": "node scripts/check-no-provider-canary-residue.mjs",
"check:fork-only-patches": "node --test scripts/check-fork-only-patches.test.mjs && node scripts/check-fork-only-patches.mjs",
"check:paw-feel-disposition-boundaries": "node --test scripts/check-paw-feel-disposition-boundaries.test.mjs && node scripts/check-paw-feel-disposition-boundaries.mjs",
"check:paw-feel-disposition-capacity": "pnpm --filter @cat-cafe/shared build && pnpm --filter @cat-cafe/api build && node --test packages/api/test/harness-eval/paw-feel-disposition-capacity.test.js",
"check:f212-reconciliation-eval": "node --test scripts/eval-f212-reconciliation/reconciliation-baseline.test.mjs scripts/eval-f212-reconciliation/reconciliation-window.test.mjs scripts/eval-f212-reconciliation/reconciliation-matcher.test.mjs scripts/eval-f212-reconciliation/reconciliation-sequence.test.mjs scripts/eval-f212-reconciliation/reconciliation-malformed.test.mjs scripts/eval-f212-reconciliation/reconciliation-compression.test.mjs",
Expand Down
16 changes: 16 additions & 0 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7415,6 +7415,10 @@ async function main(): Promise<void> {
);
// N-day factory is in its own module (split from eval-domain-daily for file-size limit)
const { createEvalDomainNDaySpec } = await import('./infrastructure/harness-eval/domain/eval-domain-nday.js');
// F192 evidence-source prerequisite gate (fork-only; see docs/fork-only-patches.md).
const { createTelemetryEvidencePrereqProbe } = await import(
'./infrastructure/harness-eval/domain/eval-domain-evidence-gate.js'
);
const { getOwnerUserId } = await import('./config/cat-config-loader.js');
// cloud R6 P2 (PR-2) + memory wire-up: mirror the same wired set the
// eval-hub.ts route computes (Object.keys(verdictGenerators)). Bootstrap-time
Expand Down Expand Up @@ -7482,6 +7486,17 @@ async function main(): Promise<void> {
publishPrereqCache.set(domainId, ok);
return ok;
};
// F192 evidence-source prerequisite gate. OTel init state is fixed for the
// process lifetime (salt is read at boot), so a boolean thunk is a complete input.
// No co-location check: f167-runtime-eval evidence is a snapshot+attribution pair
// on disk under harnessFeedbackRoot (below), written by THIS process's OTel
// pipeline. The producer is this process by construction, so the local handle is
// authoritative. See eval-domain-evidence-gate.ts for why the earlier
// EVAL_BASE_URL-derived assertion was removed rather than repaired.
const evidencePrereqProbe = createTelemetryEvidencePrereqProbe({
otelEnabled: () => telemetryHandle.getMetricsText !== null,
});

const evalScheduleOpts = {
harnessFeedbackRoot: resolve(repoRoot, 'docs', 'harness-feedback'),
threadStore,
Expand All @@ -7490,6 +7505,7 @@ async function main(): Promise<void> {
redis: redisClient ?? undefined,
wiredPublishDomains,
publishPrereqProbe,
evidencePrereqProbe,
triggerStore: redisClient
? new (
await import('./infrastructure/harness-eval/domain/eval-domain-trigger-store.js')
Expand Down
Loading