Skip to content

Commit 0e37824

Browse files
BrettNyeclaude
andauthored
fix(client): split the conflated hash in registerEnv, so an inline secret can register (#157)
* fix(client): split the conflated hash in registerEnv, so an inline secret can register Closes serve-stack KNOWN-ISSUES 20. `env.register()` carrying `secrets: { KEY: { inline: … } }` threw IntegrityMismatchError against any real StorageProvider — unconditionally, on both shipped secret stores. ROOT CAUSE. One value was asked to be two incompatible things. The hash was computed from the def while its secretRefs still held PLACEHOLDER names, then staging replaced each placeholder with a store-returned ref that is fresh on every call (`local-secret://<uuid>`, or a random-suffixed ARN), and the mutated def was written at a URI carrying the pre-staging hash. `putBlob` re-hashes what it stores and correctly rejected the mismatch. Idempotency key needs to be STABLE across stagings -> must EXCLUDE the fresh ref Content address needs to EQUAL the bytes stored -> must INCLUDE the fresh ref FIX. Separate the roles. `idempotencyKey` stays the placeholder-derived hash; `contentHash` becomes the hash of the bytes actually written, so a `sha256:` URI describes its own contents again and putBlob's check passes because it is TRUE, not because it was relaxed. This also aligns the client with the worker, which already hashes the fetched BYTES (bundle-fetcher.ts:113) — the placeholder hash was wrong at both ends. The idempotency key is persisted in the blob, because after the split `resolveLatest` reports the content address and there is nowhere else to read it from. A bundle registered before this change carries no key, reads as undefined, and is treated as not-idempotent: it re-registers once and is stable thereafter. The UUID stays, deliberately: it keeps the ref opaque in a blob that content-addressed storage hands to any reader, avoids clobbering a value an in-flight dispatch is mid-resolve() on, and gives each staging its own TTL. AND FIXED THE INSTRUMENT. The unit stub read the content hash out of the URI and never recomputed it, so it accepted a pinned URI whose hash did not match its bytes — which is exactly how an unconditionally-broken path shipped under a green suite. The stub now enforces what a real provider enforces. Mutation-verified: re-introducing the defect turns the PRE-EXISTING env-register tests red, where before they stayed green. New tests run against the REAL LocalStorageProvider, in `pnpm -r test` rather than the Docker-gated lane CI never runs — this defect's survival is attributable to that lane, so a fix verified only there would be unverified in practice. NOT VERIFIED: that AwsSecretStore fails and is fixed identically. Its ref is a random-suffixed ARN so the mechanism is the same by construction, but there are no AWS credentials in this environment and the claim rests on reading, not running. Repo-wide: build, lint, typecheck clean; every package's suite green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: record issue 20 as fixed, and that the AwsSecretStore half stays unverified Follows the code fix in this branch. - KNOWN-ISSUES 20 -> FIXED, naming both halves: the split itself, and the correction to the unit stub that hid it. The stub fix is called out as the more durable half — it is the instrument, and every test built on it was blind to this class of defect. - The at-a-glance table's "issue 20 is the only outright breakage left" line is replaced rather than deleted: nothing is currently known-broken, what remains is one open feature request (17's base-tree/patch half) and one unverified claim. - The design spec is marked IMPLEMENTED, keeping its original status visible. The unverified AwsSecretStore half is stated in all three places rather than quietly dropped. Its spec §5 AC6 says silence is not acceptable there, and a fix that reads as covering both stores when only one was exercised is exactly the overclaim this project's threat model warns against. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 64a2ffe commit 0e37824

5 files changed

Lines changed: 288 additions & 46 deletions

File tree

deploy/serve-stack/KNOWN-ISSUES.md

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@ reader does not have to scroll 2500 lines to find the one thing still broken.
3636
| **17** | dev shapes pinned to a placeholder image | **PREMISE FALSE** (17a, 17b) — base-tree/patch half still open |
3737
| **18** | `git()` decodes stdout as UTF-8 | **FIXED** (#145) |
3838
| **19** | no repository history in the workspace | **WITHDRAWN** (19a) — capabilities *can* carry `.git`, measured |
39-
| **20** | `env.register()` + inline secret always throws | **OPEN** — root-caused, fix designed, not implemented |
39+
| **20** | `env.register()` + inline secret always throws | **FIXED** (#157) — and the unit stub that hid it was corrected |
4040

41-
**If you read one line: issue 20 is the only outright breakage left.**
41+
**Nothing in this file is currently known-broken.** What remains is one open
42+
feature request (17's base-tree/patch half) and one unverified claim (whether
43+
`AwsSecretStore` behaves identically under 20's fix — no AWS credentials here).
4244

4345
Two entries — 17 and 19 — were filed on premises that did not survive
4446
verification. They are kept rather than deleted, with the original text intact
@@ -2527,15 +2529,34 @@ with shallow-depth support, rather than a consumer discovering it works by tryin
25272529
25282530
## 20. `env.register()` with an inline secret always throws `IntegrityMismatchError`
25292531
2530-
**Status: OPEN — root-caused, fix designed, NOT implemented.** The only
2531-
outright-broken item left in this file. Reproduced with a minimal case plus a
2532-
control; the cause is a conflated key at `pangolin-client/src/env-register.ts`,
2533-
where `computeContentHash(def)` serves as BOTH the content address and the
2534-
idempotency key. The content address must INCLUDE the freshly-staged secret ref;
2535-
the idempotency key must EXCLUDE it. Splitting the two is the fix
2536-
([#148](https://github.com/QuarrySystems/pangolin/pull/148) files it and specs
2537-
the split). Keeping the UUID suffix was decided deliberately — for opacity,
2538-
non-clobbering re-stage, and per-staging TTL.
2532+
**Status: FIXED** ([#157](https://github.com/QuarrySystems/pangolin/pull/157);
2533+
filed and spec'd in [#148](https://github.com/QuarrySystems/pangolin/pull/148)).
2534+
The two roles are now separate values in `registerEnv`: `idempotencyKey` remains
2535+
the placeholder-derived hash, and `contentHash` is the hash of the bytes actually
2536+
written — so a `sha256:` URI describes its own contents again and `putBlob`'s
2537+
check passes because it is true, not because it was relaxed. That also aligns the
2538+
client with the worker, which already re-hashes the fetched BYTES
2539+
(`bundle-fetcher.ts:113`); the placeholder hash was wrong at both ends.
2540+
2541+
The key is persisted in the blob, since after the split `resolveLatest` reports
2542+
the content address and there is nowhere else to read it from. Bundles registered
2543+
before the change carry no key, read as `undefined`, and re-register once.
2544+
2545+
The UUID stayed, as the design argued: opacity in a blob that content-addressed
2546+
storage hands to any reader, no clobbering of a value an in-flight dispatch is
2547+
mid-`resolve()` on, and a per-staging TTL.
2548+
2549+
**The instrument was fixed too, and that is the more durable half.** The unit
2550+
stub read the content hash out of the URI and never recomputed it, so it accepted
2551+
a pinned URI whose hash did not match its bytes — which is exactly how an
2552+
unconditionally-broken path shipped under a green suite. It now enforces what a
2553+
real provider enforces. Mutation-verified: re-introducing the defect turns the
2554+
PRE-EXISTING tests red, where before they stayed green.
2555+
2556+
**Still unverified:** that `AwsSecretStore` fails and is fixed identically. Its
2557+
ref is a random-suffixed ARN so the mechanism is the same by construction, but
2558+
there are no AWS credentials in this environment and the claim rests on reading,
2559+
not running.
25392560
25402561
**Unconditional, not flaky, and it affects both shipped secret stores.** A
25412562
registration carrying `secrets: { KEY: { inline: … } }` cannot succeed against a

docs/superpowers/specs/2026-08-03-env-register-split-key-design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Split the Conflated Content Hash in `registerEnv` — Design
33
date: 2026-08-03
4-
status: **DESIGNED — ready for a plan.** Root cause confirmed by minimal reproduction with a control. One half is explicitly NOT verified and is marked inline.
4+
status: **IMPLEMENTED (2026-08-03, #157).** The split shipped; the AwsSecretStore half remains unverified (no AWS credentials in this environment). **Originally:** DESIGNED — ready for a plan. Root cause confirmed by minimal reproduction with a control. One half is explicitly NOT verified and is marked inline.
55
branch: fix/env-register-split-key-spec
66
authors: [human:Brett, agent:claude-opus-5]
77
severity: high (a documented, shipped API path cannot succeed against real storage)

packages/pangolin-client/src/env-register.ts

Lines changed: 69 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,7 @@ function inlineSecretPlaceholder(envBundleName: string, secretKey: string): stri
100100
* Idempotent: re-registering with identical inputs returns the existing
101101
* `EnvRef` without bumping `registeredAt` or writing a new blob.
102102
*/
103-
export async function registerEnv(
104-
client: PangolinClient,
105-
opts: RegisterEnvOpts,
106-
): Promise<EnvRef> {
103+
export async function registerEnv(client: PangolinClient, opts: RegisterEnvOpts): Promise<EnvRef> {
107104
// 1. Scan `values:` entries for credential patterns. First match throws
108105
// so the caller fixes one finding and re-runs.
109106
const values = opts.values ?? {};
@@ -142,9 +139,7 @@ export async function registerEnv(
142139
let store: SecretStore | undefined;
143140
if (inlineSecretKeys.length > 0) {
144141
if (!opts.secretStore) {
145-
throw new Error(
146-
'registerEnv: secretStore is required when the bundle has inline secrets',
147-
);
142+
throw new Error('registerEnv: secretStore is required when the bundle has inline secrets');
148143
}
149144
store = client.secretStores[opts.secretStore];
150145
if (!store) {
@@ -162,7 +157,19 @@ export async function registerEnv(
162157
// Undefined for pure-values / ref-only bundles (no staging needed).
163158
store: store?.name,
164159
};
165-
const contentHash = computeContentHash(def);
160+
// The IDEMPOTENCY KEY, not the content address. Hashed from the def while its
161+
// secretRefs still hold PLACEHOLDER names, so it is stable across stagings —
162+
// which is exactly what an idempotency key needs and exactly what a content
163+
// address must not be.
164+
//
165+
// These were one value until KNOWN-ISSUES 20. They cannot be: staging below
166+
// replaces every placeholder with a store-returned ref that is fresh on every
167+
// call (`local-secret://<uuid>`, or a random-suffixed ARN), so a hash taken
168+
// before staging can never describe the bytes written after it. `putBlob`
169+
// re-hashes what it stores and correctly rejected the mismatch, making
170+
// `env.register()` with an inline secret fail unconditionally against any real
171+
// StorageProvider.
172+
const idempotencyKey = computeContentHash(def);
166173

167174
const baseUri = buildPangolinUri({
168175
namespace: client.namespace,
@@ -176,12 +183,19 @@ export async function registerEnv(
176183
// before this check would crash on the second identical call
177184
// (ResourceExistsException) or hand back a fresh ref that breaks
178185
// hash equality.
186+
// The comparison is against the key PERSISTED IN THE PREVIOUS BLOB, not
187+
// against `latest.contentHash` — since the split, that is the hash of the
188+
// stored bytes (post-staging) and so is not comparable to a key computed
189+
// pre-staging. A bundle registered before this change carries no key and
190+
// reads as `undefined`, which is treated as "not idempotent": it
191+
// re-registers once, re-stages once, and is stable thereafter.
179192
const latest = await client.storage.resolveLatest(baseUri);
180-
if (latest && latest.contentHash === contentHash) {
193+
if (latest && (await readIdempotencyKey(client, latest.uri)) === idempotencyKey) {
181194
return {
182195
name: opts.name,
183196
registeredAt: latest.registeredAt,
184-
contentHash,
197+
// The stored blob's real content address, NOT the idempotency key.
198+
contentHash: latest.contentHash,
185199
};
186200
}
187201

@@ -206,30 +220,60 @@ export async function registerEnv(
206220
}
207221
}
208222

209-
// 5. Write the bundle payload at the pinned URI. The pinned URI uses
210-
// the placeholder-derived contentHash (stable across calls); the
211-
// blob body carries the real opaque refs.
223+
// 5. Write the bundle payload at the pinned URI.
224+
//
225+
// The payload carries the idempotency key alongside the real opaque refs,
226+
// because the next `registerEnv` call has nowhere else to read it from:
227+
// `resolveLatest` reports the content address, which by construction now
228+
// reflects post-staging bytes.
229+
//
230+
// Write the CANONICAL JSON bytes (sorted-key serialization) — not
231+
// `JSON.stringify`. The storage provider recomputes the byte-hash and
232+
// compares against the pinned URI's hash, and the worker's bundle-fetcher
233+
// re-hashes the fetched BYTES the same way, so insertion-order JSON would
234+
// diverge on both sides.
235+
const payload = { ...def, idempotencyKey };
236+
const bytes = new TextEncoder().encode(canonicalJsonString(payload));
237+
238+
// The content address is the hash of the bytes ACTUALLY WRITTEN. A
239+
// `sha256:` URI therefore describes its own contents again, and `putBlob`'s
240+
// check passes because it is true — not because it was relaxed.
241+
const contentHash = computeContentHash(bytes);
212242
const pinnedUri = buildPangolinUri({
213243
namespace: client.namespace,
214244
type: 'env',
215245
name: opts.name,
216246
contentHash,
217247
});
218-
// Write the CANONICAL JSON bytes (sorted-key serialization) — not
219-
// `JSON.stringify(def)`. The storage provider recomputes the byte-hash
220-
// and compares against the pinned URI's hash; if we wrote insertion-
221-
// order JSON, the byte-hash would diverge from the canonical-object
222-
// hash embedded in the URI and `put` would throw IntegrityMismatchError.
223-
// The worker's bundle-fetcher re-parses these bytes as JSON and
224-
// re-hashes the resulting object via canonical JSON, so the round-trip
225-
// remains coherent on both sides.
226-
await client.storage.put(
227-
pinnedUri,
228-
new TextEncoder().encode(canonicalJsonString(def)),
229-
);
248+
await client.storage.put(pinnedUri, bytes);
230249

231250
// The storage layer is the authority on registeredAt — re-read it.
232251
const after = await client.storage.resolveLatest(baseUri);
233252
const registeredAt = after?.registeredAt ?? new Date().toISOString();
234253
return { name: opts.name, registeredAt, contentHash };
235254
}
255+
256+
/**
257+
* Read the idempotency key persisted in a previously-registered env blob.
258+
*
259+
* NEVER throws: a missing blob, unreadable bytes, non-JSON content, or a blob
260+
* predating this field all yield `undefined`, which the caller treats as "not
261+
* idempotent". Failing closed here would turn an unreadable prior registration
262+
* into a hard error on a path whose whole job is to skip redundant work.
263+
*/
264+
async function readIdempotencyKey(
265+
client: PangolinClient,
266+
pinnedUri: string,
267+
): Promise<string | undefined> {
268+
try {
269+
const bytes = await client.storage.get(pinnedUri);
270+
const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
271+
if (parsed && typeof parsed === 'object') {
272+
const key = (parsed as Record<string, unknown>).idempotencyKey;
273+
if (typeof key === 'string') return key;
274+
}
275+
return undefined;
276+
} catch {
277+
return undefined;
278+
}
279+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// registerEnv against a REAL StorageProvider (serve-stack KNOWN-ISSUES 20).
2+
//
3+
// Deliberately NOT using the in-memory stub from env-register.test.ts. That stub
4+
// reads the content hash out of the URI instead of computing it from the bytes
5+
// (`env-register.test.ts` `put`: `parts[parts.length - 1]`), so it accepts a
6+
// pinned URI whose hash does not describe its own contents. Every test built on
7+
// it is blind to this entire class of defect — the instrument was lying, which
8+
// is why an unconditionally-broken API path shipped.
9+
//
10+
// `LocalStorageProvider` performs the same byte re-hash the worker does
11+
// (`bundle-fetcher.ts:113` hashes the fetched BYTES), so this file exercises the
12+
// invariant both ends actually enforce.
13+
//
14+
// Lane: this is a normal unit test, run by `pnpm -r test`. The defect survived
15+
// because its only executing check lived in the Docker-gated E2E suite, which CI
16+
// does not run — a fix verified only there would be unverified in practice.
17+
18+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
19+
import { mkdtemp, rm } from 'node:fs/promises';
20+
import { tmpdir } from 'node:os';
21+
import { join } from 'node:path';
22+
import { randomUUID } from 'node:crypto';
23+
import { PangolinClient } from '../src/index.js';
24+
import { registerEnv } from '../src/env-register.js';
25+
import { LocalStorageProvider } from '@quarry-systems/pangolin-storage-local';
26+
import { computeContentHash } from '@quarry-systems/pangolin-core';
27+
import type { SecretStore } from '@quarry-systems/pangolin-core';
28+
29+
/**
30+
* Mimics the shape that breaks hash equality: `LocalSecretStore.stage` returns
31+
* `local-secret://<randomUUID>`, and `AwsSecretStore` a random-suffixed ARN.
32+
* The randomness is the point — a name-derived ref would hide the defect.
33+
*/
34+
function makeCountingStore(): SecretStore & { stageCalls: string[] } {
35+
const stageCalls: string[] = [];
36+
return {
37+
name: 'counting',
38+
stageCalls,
39+
async stage({ name }: { name: string }) {
40+
stageCalls.push(name);
41+
return { ref: `local-secret://${randomUUID()}` };
42+
},
43+
async resolve() {
44+
return 'unused';
45+
},
46+
} as unknown as SecretStore & { stageCalls: string[] };
47+
}
48+
49+
describe('registerEnv integrity against a real StorageProvider', () => {
50+
let root: string;
51+
let storage: LocalStorageProvider;
52+
53+
beforeEach(async () => {
54+
root = await mkdtemp(join(tmpdir(), 'env-reg-'));
55+
storage = new LocalStorageProvider({ rootDir: root });
56+
});
57+
afterEach(async () => {
58+
await rm(root, { recursive: true, force: true });
59+
});
60+
61+
function makeClient(store: SecretStore): PangolinClient {
62+
return new PangolinClient({
63+
namespace: 'ns',
64+
compute: {},
65+
credentials: {},
66+
storage,
67+
targets: {},
68+
secretStores: { default: store },
69+
});
70+
}
71+
72+
it('registers a bundle carrying an inline secret without throwing IntegrityMismatchError', async () => {
73+
const store = makeCountingStore();
74+
const res = await registerEnv(makeClient(store), {
75+
name: 'prod',
76+
values: { REGION: 'us-west-2' },
77+
secrets: { GH_TOKEN: { inline: 'super-secret-value' } },
78+
secretStore: 'default',
79+
});
80+
expect(res.name).toBe('prod');
81+
expect(res.contentHash).toMatch(/^sha256:[0-9a-f]{64}$/);
82+
// Control: the staging actually happened, so this is a real inline path and
83+
// not a bundle that quietly took the no-secrets branch.
84+
expect(store.stageCalls).toHaveLength(1);
85+
});
86+
87+
it("the pinned URI's hash equals a re-hash of the stored bytes", async () => {
88+
const store = makeCountingStore();
89+
const res = await registerEnv(makeClient(store), {
90+
name: 'prod',
91+
secrets: { GH_TOKEN: { inline: 'super-secret-value' } },
92+
secretStore: 'default',
93+
});
94+
// Asserted directly rather than inferred from the absence of a throw: this
95+
// is the invariant putBlob enforces and the worker re-checks.
96+
const bytes = await storage.get(`pangolin://ns/env/prod/${res.contentHash}`);
97+
expect(computeContentHash(bytes)).toBe(res.contentHash);
98+
});
99+
100+
it('the stored blob carries the real opaque ref, not the placeholder and not the secret value', async () => {
101+
const store = makeCountingStore();
102+
const res = await registerEnv(makeClient(store), {
103+
name: 'prod',
104+
secrets: { GH_TOKEN: { inline: 'super-secret-value' } },
105+
secretStore: 'default',
106+
});
107+
const body = new TextDecoder().decode(
108+
await storage.get(`pangolin://ns/env/prod/${res.contentHash}`),
109+
);
110+
// A presence beside the two absences, so the absences are not an empty read.
111+
expect(body).toContain('local-secret://');
112+
expect(body).not.toContain('super-secret-value');
113+
expect(body).not.toContain('pangolin/inline/env-prod/GH_TOKEN');
114+
});
115+
116+
it('registering the same bundle twice stages exactly once and reuses registeredAt', async () => {
117+
const store = makeCountingStore();
118+
const client = makeClient(store);
119+
const opts = {
120+
name: 'prod',
121+
values: { REGION: 'us-west-2' },
122+
secrets: { GH_TOKEN: { inline: 'super-secret-value' } },
123+
secretStore: 'default',
124+
};
125+
const first = await registerEnv(client, { ...opts });
126+
const second = await registerEnv(client, { ...opts });
127+
128+
// The single stage call is the control proving the early return fired,
129+
// rather than the test never reaching the idempotent path.
130+
expect(store.stageCalls).toHaveLength(1);
131+
expect(second.registeredAt).toBe(first.registeredAt);
132+
});
133+
134+
it('registering a bundle whose values changed stages again and yields a different pinned URI', async () => {
135+
const store = makeCountingStore();
136+
const client = makeClient(store);
137+
const first = await registerEnv(client, {
138+
name: 'prod',
139+
values: { REGION: 'us-west-2' },
140+
secrets: { GH_TOKEN: { inline: 'v1' } },
141+
secretStore: 'default',
142+
});
143+
const second = await registerEnv(client, {
144+
name: 'prod',
145+
values: { REGION: 'eu-west-1' },
146+
secrets: { GH_TOKEN: { inline: 'v1' } },
147+
secretStore: 'default',
148+
});
149+
expect(store.stageCalls).toHaveLength(2);
150+
expect(second.contentHash).not.toBe(first.contentHash);
151+
});
152+
153+
it('a bundle with no inline secrets still round-trips (regression guard on the unchanged path)', async () => {
154+
const store = makeCountingStore();
155+
const res = await registerEnv(makeClient(store), {
156+
name: 'plain',
157+
values: { REGION: 'us-west-2' },
158+
});
159+
const bytes = await storage.get(`pangolin://ns/env/plain/${res.contentHash}`);
160+
expect(computeContentHash(bytes)).toBe(res.contentHash);
161+
expect(store.stageCalls).toHaveLength(0);
162+
});
163+
});

0 commit comments

Comments
 (0)