Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,6 @@ journey-kit/dist/
skills/
skills-lock.json
.gstack/

# Local Secret Manager emulator overrides
functions/.secret.local
36 changes: 31 additions & 5 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,9 @@ Start from the template:
cp /Users/nick/git/metacortex/functions/.env.example /Users/nick/git/metacortex/functions/.env
```

Minimum required production values:
Minimum required non-secret production values:

```dotenv
GEMINI_API_KEY=...
MCP_ADMIN_TOKEN=...
GEMINI_EMBEDDING_MODEL=...
GEMINI_MULTIMODAL_MODEL=...
GEMINI_MERGE_MODEL=...
Expand Down Expand Up @@ -126,7 +124,8 @@ Recommended browser read/write toolset:

Do not add `deprecate_context` to browser-hosted client profiles. Keep it on the admin endpoint only.

Recommended web client profile shape:
Recommended web client profile shape (store the JSON value in Secret Manager,
not in production dotenv):

```dotenv
MCP_CLIENT_PROFILES_JSON=[{"id":"chatgpt-web","token":"replace-chatgpt-token","allowedTools":["remember_context","search_context","fetch_context"],"allowedFilterStates":["active"],"allowedOrigins":["https://chatgpt.com"]},{"id":"claude-web","token":"replace-claude-token","allowedTools":["remember_context","search_context","fetch_context"],"allowedFilterStates":["active"],"allowedOrigins":["https://claude.ai"]}]
Expand Down Expand Up @@ -504,7 +503,7 @@ Use separate tokens for separate trust boundaries:

Rotation and revocation rules:

- rotate a web client token by changing that profile's `token` and redeploying functions
- rotate a web client token by changing that profile's `token` in the `MCP_CLIENT_PROFILES_JSON` Secret Manager version, updating its consumer, and redeploying functions
- revoke a client by removing the profile or replacing its token and redeploying functions
- do not reuse `MCP_ADMIN_TOKEN` for browser-hosted clients
- if ChatGPT web and Claude web should be revoked independently, give them separate client profiles
Expand Down Expand Up @@ -597,3 +596,30 @@ firebase functions:list
```

Use Firebase console logs or Cloud Logging for failed production requests.


## Production secret storage and rotation

`functions/src/index.ts` binds `MCP_ADMIN_TOKEN`, `GEMINI_API_KEY`, and
`MCP_CLIENT_PROFILES_JSON` with `defineSecret`. Production dotenv files must not
contain these keys; retain only non-secret model, collection and service settings.
`config.ts` continues reading runtime environment values injected by Firebase.
Local emulators may use private `.secret.local` overrides (never commit them).

Use Secret Manager in `my-brain-88870`. Transfer secret bytes through stdin or an
in-memory SDK call, never command arguments or terminal output. A new secret
version is applied by redeploying `metaCortexMcp`; existing instances do not
automatically switch versions. Verify that function metadata lists all three
under `secretEnvironmentVariables`, with none under `environmentVariables`.
Do not print a raw function description while migrating an older deployment.

Storage migration preserves values and does not constitute rotation. Before
rotating the profile bundle, inventory every current client and its configured
endpoint. Store each replacement as `metacortex-client-<id>` and update its
consumer in the same cutover. Browser/remote client settings require access to
those clients; storing a token in Secret Manager alone is not distribution.
Smoke-test `tools/list` for each endpoint, then perform a read-only embedding
search to exercise the Gemini credential. Only retire previous credentials once
consumer cutover and verification are complete. Do not restore plaintext env
configuration for rollback: redeploy a known-good code revision with the secret
bindings retained and explicitly selected prior secret versions if necessary.
37 changes: 37 additions & 0 deletions docs/operations/2026-09-09-secret-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Card-23: production secret migration

As of 2026-09-09 UTC, production revision `metacortexmcp-00028-pul` is ACTIVE
and binds version 1 of `MCP_ADMIN_TOKEN`, `GEMINI_API_KEY`, and
`MCP_CLIENT_PROFILES_JSON` from Secret Manager in `my-brain-88870`.
None of those keys remains in `serviceConfig.environmentVariables`.

The existing values were transferred in memory directly to Secret Manager;
no credential bytes were printed or written to a temporary file. The original
checkout's `functions/.env.prod` had the three secret entries removed after
production verification. The isolated deployment used the existing non-secret
production settings, preserving model and collection configuration.

## Verification

- 82 tests passed with coverage; TypeScript build passed.
- Deployment preflight passed, including enabled Secret Manager versions.
- Synthetic preflight check rejected a plaintext dotenv credential without
printing the synthetic value.
- Authenticated MCP `tools/list` succeeded for admin and all nine scoped clients:
chatgpt-web, claude-web, openclaw, antigravity-ide, claude-code-windows,
claude-code-mac, gemini-spark, grok-bot, grok.
- A read-only `search_context` request through the grok client succeeded,
exercising the injected Gemini key and retrieval path. No memory was created.
- Production function metadata confirmed all three secret references and no
remaining plaintext secret keys.

## Completed scope

Nick explicitly removed rotation from card-23 on 2026-09-09 UTC ("no rotation").
The completed scope is storage migration with existing credential values
preserved. Credential rotation and client reconfiguration are not outstanding
requirements for this card. No rotation was performed.

Future deployments must retain the secret bindings in this branch. Do not rerun
the original plaintext migration against the already-migrated deployment.
Rollback must not restore ordinary environment variables containing credentials.
8 changes: 7 additions & 1 deletion functions/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { defineSecret } from "firebase-functions/params";
import { onRequest } from "firebase-functions/v2/https";

import { createMetaCortexApp } from "./app.js";
import { getConfig, getObserver, getRuntime } from "./runtime.js";

const mcpAdminToken = defineSecret("MCP_ADMIN_TOKEN");
const geminiApiKey = defineSecret("GEMINI_API_KEY");
const clientProfiles = defineSecret("MCP_CLIENT_PROFILES_JSON");

export const metaCortexMcp = onRequest(
{
region: "us-central1",
timeoutSeconds: 300,
memory: "512MiB",
invoker: "public"
invoker: "public",
secrets: [mcpAdminToken, geminiApiKey, clientProfiles]
},
createMetaCortexApp({
getConfig,
Expand Down
35 changes: 29 additions & 6 deletions scripts/deploy-session-preflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ if [[ -f functions/.env.prod ]]; then

missing_keys=()

for required_key in GEMINI_API_KEY MCP_ADMIN_TOKEN GEMINI_EMBEDDING_DIMENSIONS; do
for required_key in GEMINI_EMBEDDING_DIMENSIONS; do
if [[ -z "$(read_env_key functions/.env.prod "$required_key")" ]]; then
missing_keys+=("$required_key")
fi
Expand All @@ -81,6 +81,30 @@ if [[ -f functions/.env ]]; then
fi

echo
echo "== Production secrets =="
node - <<'NODE'
const fs = require("fs");
const names = new Set(["GEMINI_API_KEY", "MCP_ADMIN_TOKEN", "MCP_CLIENT_PROFILES_JSON"]);
for (const file of ["functions/.env", "functions/.env.prod", "functions/.env.my-brain-88870"]) {
if (!fs.existsSync(file)) continue;
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
const key = line.split("=", 1)[0].trim();
if (names.has(key)) {
console.error(`ERROR: ${file} contains secret key ${key}; move it to Secret Manager before deployment.`);
process.exitCode = 1;
}
}
}
NODE
for secret_name in GEMINI_API_KEY MCP_ADMIN_TOKEN MCP_CLIENT_PROFILES_JSON; do
secret_state="$(gcloud secrets versions describe latest --secret="$secret_name" --project=my-brain-88870 --format='value(state)')"
if [[ "$secret_state" != "ENABLED" ]]; then
echo "ERROR: production secret $secret_name is not enabled" >&2
exit 1
fi
echo "$secret_name: enabled"
done

echo "== Client profiles =="
if [[ -f functions/.env.prod ]]; then
node - <<'NODE'
Expand All @@ -96,9 +120,8 @@ const line = envText
});

if (!line) {
console.log(
"warning: functions/.env.prod does not define MCP_CLIENT_PROFILES_JSON; browser-hosted clients will not have a scoped endpoint"
);
// Production profiles are injected from Secret Manager, not dotenv.
console.log("Client profiles use the MCP_CLIENT_PROFILES_JSON runtime secret.");
process.exit(0);
}

Expand Down Expand Up @@ -343,8 +366,8 @@ try {
echo "warning: could not read active project alias from Firebase configstore — skipping alias check"
elif [[ "$STORED_ALIAS" != "$EXPECTED_ALIAS" ]]; then
echo "ERROR: active project is set to '${STORED_ALIAS}', not the '${EXPECTED_ALIAS}' alias." >&2
echo " Deploying with the raw project ID skips functions/.env.prod and omits MCP_CLIENT_PROFILES_JSON," >&2
echo " causing all client endpoints to return 404." >&2
echo " Deploying with the raw project ID skips non-secret settings in functions/.env.prod," >&2
echo " potentially changing runtime model or collection settings." >&2
echo " Fix: firebase use ${EXPECTED_ALIAS}" >&2
exit 1
else
Expand Down
4 changes: 4 additions & 0 deletions studies/ci-recurrence/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
cache/
out/
__pycache__/
*.pyc
129 changes: 129 additions & 0 deletions studies/ci-recurrence/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# CI failure recurrence study

**Question.** Over 90 days on one repo, walk failure events chronologically. A
failure is a RECURRENCE if its fingerprint appeared earlier in the window.
`rate = recurrences / total_failures`.

**Decision rule.** Below roughly 30% at the most defensible fingerprinting, the
premise is dead.

## Status

Tier 0 and the collection/analysis path are built and tested. **No recurrence
rate has been computed yet** — the session this was written in cannot reach the
GitHub Actions API for `pytorch/pytorch` or `ankidroid/Anki-Android` (see
`findings.md`). Run the two commands below on the study machine and the numbers
drop out.

The Tier 1 clustering loop is deliberately **not built yet**. It should not be
written until the Tier 0 collapse rate says how many singletons it would have
to chew through — that number decides whether Tier 1 is a night of local
inference or a week of it.

## Run it

```bash
export GITHUB_TOKEN=<classic PAT with NO scopes ticked>

uv run fetch.py --repo ankidroid/Anki-Android --since-days 90
uv run analyze.py --repo ankidroid/Anki-Android --top 50
```

### What token

**A classic PAT with zero scopes checked.** Both targets are public, and every
endpoint this study touches is public-readable:

| Endpoint | Needs |
|---|---|
| `/repos/{o}/{r}/actions/runs` | nothing, for a public repo |
| `/repos/{o}/{r}/actions/runs/{id}/jobs` | nothing, for a public repo |
| `/repos/{o}/{r}/check-runs/{id}/annotations` | nothing, for a public repo |

The token is not there for access, it is there for **rate limit**:
unauthenticated is 60 requests/hour, which makes a 90-day crawl impossible; any
valid token raises that to 5,000/hour regardless of its scopes.

Do **not** tick `public_repo`. That scope grants *write* to every public repo
you can see — push, issues, the lot — and buys this study nothing. A scopeless
token can only read public data, which is exactly the blast radius we want for
something crawling two repos we don't own.

A fine-grained PAT also works: set the resource owner to your own account and
choose **"Public repositories (read-only)"**. Note you cannot scope a
fine-grained PAT *to* `pytorch/pytorch` — fine-grained tokens only target repos
you own, so the public-read option is the route. The scopeless classic token is
simpler and no less safe.

`fetch.py` caches every API response under `cache/` keyed by URL hash, so a
re-run costs no API calls and the window can be rebuilt offline. `analyze.py`
touches no network and no model — rerun it freely.

For pytorch, scope the crawl or it will run for hours:

```bash
uv run fetch.py --repo pytorch/pytorch --since-days 90 \
--workflow trunk --workflow pull --max-runs 5000
```

Tests (no network, no model, no tokens):

```bash
uv run test_pipeline.py
```

## The cascade

| Tier | Who | Handles | Cost |
|---|---|---|---|
| 0 | regex normalizer | everything with a matching fingerprint | free, reproducible, hashable |
| 1 | local model via Ollama | Tier 0 singletons only | electricity |
| 2 | Gemini Flash | low-confidence Tier 1, or when local is the bottleneck | metered |
| 3 | coordinator | audit of 20 sampled labels per batch | attention |

Tier 0 is the one that matters. An LLM label is not reproducible and not
hashable; a regex fingerprint is both. Every event Tier 0 places in a group of
2+ is an event no worker ever has to look at.

## Files

| File | What |
|---|---|
| `schema.sql` | SQLite: queue, cache, and checkpoint in one file |
| `normalize.py` | **Tier 0.** Normalization rules + fingerprint |
| `fetch.py` | GitHub Actions collector, disk-cached, read-only |
| `analyze.py` | Flake split, Tier 0 collapse stats, chronological walk |
| `prompts.py` | **Tier 1 worker prompt.** Version it on every edit |
| `dispatch.py` | The whole harness: one `ask()`, content-hash cache, retry, tokens |
| `test_pipeline.py` | Known-answer tests for the model-free parts |

## Three decisions worth arguing with

**Exit codes survive number-stripping.** `exit code 137` is an OOM kill and
`exit code 143` is a timeout; `exit code 1` is a test failing. Stripping them as
"bare ints" merges infrastructure failures into test failures and inflates the
rate. `KEEP_EXIT_CODES` in `normalize.py` toggles it so the collapse rate can be
reported both ways.

**Path basenames survive, directory prefixes don't.** `test_nn.py` and
`test_optim.py` are different failures; `/home/runner/work/...` vs
`/opt/actions-runner/_work/...` is the same failure on a different machine. So
absolute paths collapse to `<PATH>/test_nn.py`. Repo-relative paths from Check
Run annotations are left fully intact — they are identical on every runner, so
they are signal, not noise.

**The worker is told to answer "different" when torn.** Merging is the
destructive direction: every bad merge turns a first-seen failure into a
recurrence and pushes the rate up, toward the 30% line we are testing against.
The reported rate is therefore a lower bound, which is the only version worth
betting on.

## One correctness trap, documented so nobody re-introduces it

You cannot find flakes by listing `status=failure` runs. When a failed run is
re-run and passes, GitHub **rewrites the run's conclusion to `success`** — the
flaky run vanishes from the failure list, taking the fail-then-pass evidence
with it. So `fetch.py` lists all *completed* runs and pulls jobs for any run
that is non-success **or** has `run_attempt > 1`, and records every job outcome
including successes. Filtering on failure would silently drop the flakes and
leave a rate that cannot be corrected after the fact.
Loading
Loading