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
29 changes: 24 additions & 5 deletions src/integrations/ownership-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
type ManagedContribution,
type ManagedFragment,
} from "../clients/config-export";
import { canonicalContribution, fingerprint } from "./ownership";
import { canonicalContribution, fingerprint, semanticContribution } from "./ownership";

type JsonObject = Record<string, unknown>;

Expand Down Expand Up @@ -124,11 +124,10 @@ export function validRefreshablePaths(
});
}

/** Fingerprint a contribution after removing only its explicitly refreshable paths. */
export function protectedContributionFingerprint(
function contributionWithoutRefreshablePaths(
contribution: ManagedContribution,
refreshablePaths: readonly (readonly string[])[],
): string {
): ManagedContribution {
const fragments = contribution.fragments.map(cloneFragment);
for (const refreshablePath of refreshablePaths) {
for (const fragment of fragments) {
Expand All @@ -137,5 +136,25 @@ export function protectedContributionFingerprint(
break;
}
}
return fingerprint(canonicalContribution({ ...contribution, fragments }));
return { ...contribution, fragments };
}

/** Fingerprint a contribution after removing only its explicitly refreshable paths. */
export function protectedContributionFingerprint(
contribution: ManagedContribution,
refreshablePaths: readonly (readonly string[])[],
): string {
return fingerprint(canonicalContribution(
contributionWithoutRefreshablePaths(contribution, refreshablePaths),
));
}

/** Semantic protected fingerprint that ignores JSON object-key order only. */
export function semanticProtectedContributionFingerprint(
contribution: ManagedContribution,
refreshablePaths: readonly (readonly string[])[],
): string {
return fingerprint(semanticContribution(
contributionWithoutRefreshablePaths(contribution, refreshablePaths),
));
}
38 changes: 36 additions & 2 deletions src/integrations/ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,38 @@ export function fingerprint(text: string): string {
}

/**
* Canonical bytes of a contribution. Fragments are sorted by path so two builds
* of the same contribution hash identically regardless of emission order.
* Canonicalize JSON object members recursively for semantic comparisons.
* Arrays stay ordered because their position can carry configuration meaning.
*/
function semanticJsonValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(semanticJsonValue);
if (value === null || typeof value !== "object") return value;

const record = value as Record<string, unknown>;
return Object.fromEntries(
Object.keys(record)
.sort()
.map(key => [key, semanticJsonValue(record[key])]),
);
}

/**
* Stable semantic bytes of a contribution. Third-party clients may
* re-serialize JSON object members in a different order; that formatting-only
* rewrite must not look like a protected-value edit.
*/
export function semanticContribution(contribution: ManagedContribution): string {
const sorted = [...contribution.fragments].sort((a, b) => {
const left = a.path.join("\u0000");
const right = b.path.join("\u0000");
return left < right ? -1 : left > right ? 1 : 0;
});
return JSON.stringify(sorted.map(fragment => [fragment.path, semanticJsonValue(fragment.value)]));
}

/**
* Legacy-compatible bytes used by existing persisted fingerprints. Fragment
* paths are stable, while nested object insertion order remains exact.
*/
export function canonicalContribution(contribution: ManagedContribution): string {
const sorted = [...contribution.fragments].sort((a, b) => {
Expand All @@ -41,11 +71,15 @@ export interface OwnershipRecord {
fileFingerprint: string;
/** Hash of our contribution — detects catalog/port drift. */
blockFingerprint: string;
/** Key-order-independent companion for JSON clients that normalize objects. */
semanticBlockFingerprint?: string;
/**
* Hash of the fields the client must not rewrite. Present only when a
* client has explicitly declared runtime-derived paths below.
*/
protectedBlockFingerprint?: string;
/** Key-order-independent companion to `protectedBlockFingerprint`. */
semanticProtectedBlockFingerprint?: string;
/**
* Exact document paths a client may derive after apply. These are recorded
* per operation so later catalog changes cannot widen an older grant.
Expand Down
47 changes: 40 additions & 7 deletions src/integrations/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel
import type { OcxConfig } from "../types";
import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
import { SNAPSHOT_RETENTION } from "./journal";
import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership";
import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership";
import {
protectedContributionFingerprint,
refreshablePathsOf,
semanticProtectedContributionFingerprint,
validRefreshablePaths,
} from "./ownership-policy";
import { INTEGRATION_CLIENTS, type IntegrationClientId } from "./registry";
Expand Down Expand Up @@ -153,21 +154,49 @@ function recordedBlockIsOwned(
if (!observed) return false;
if (fingerprint(canonicalContribution(observed)) === record.blockFingerprint) return true;

const observedSemanticFingerprint = fingerprint(semanticContribution(observed));
if (
typeof record.semanticBlockFingerprint === "string"
&& observedSemanticFingerprint === record.semanticBlockFingerprint
) return true;

const desiredFingerprint = fingerprint(canonicalContribution(desired));
if (
desiredFingerprint === record.blockFingerprint
&& observedSemanticFingerprint === fingerprint(semanticContribution(desired))
) return true;

if (
typeof record.protectedBlockFingerprint === "string"
&& validRefreshablePaths(observed, record.refreshablePaths)
&& record.refreshablePaths.length > 0
) {
return protectedContributionFingerprint(observed, record.refreshablePaths)
=== record.protectedBlockFingerprint;
const observedProtectedFingerprint = protectedContributionFingerprint(
observed,
record.refreshablePaths,
);
if (observedProtectedFingerprint === record.protectedBlockFingerprint) return true;

const observedSemanticProtectedFingerprint = semanticProtectedContributionFingerprint(
observed,
record.refreshablePaths,
);
if (
typeof record.semanticProtectedBlockFingerprint === "string"
&& observedSemanticProtectedFingerprint === record.semanticProtectedBlockFingerprint
) return true;

return protectedContributionFingerprint(desired, record.refreshablePaths)
=== record.protectedBlockFingerprint
&& observedSemanticProtectedFingerprint
=== semanticProtectedContributionFingerprint(desired, record.refreshablePaths);
}

const desiredFingerprint = fingerprint(canonicalContribution(desired));
if (desiredFingerprint !== record.blockFingerprint) return false;
const legacyPaths = refreshablePathsOf(desired);
return legacyPaths.length > 0
&& protectedContributionFingerprint(observed, legacyPaths)
=== protectedContributionFingerprint(desired, legacyPaths);
&& semanticProtectedContributionFingerprint(observed, legacyPaths)
=== semanticProtectedContributionFingerprint(desired, legacyPaths);
}

/**
Expand Down Expand Up @@ -257,7 +286,11 @@ export function classifyIntegration(input: {
}
return { state: "stale" };
}
return input.record.blockFingerprint === fingerprint(canonicalContribution(input.contribution))
const desiredFingerprint = typeof input.record.semanticBlockFingerprint === "string"
? fingerprint(semanticContribution(input.contribution))
: fingerprint(canonicalContribution(input.contribution));
const recordedFingerprint = input.record.semanticBlockFingerprint ?? input.record.blockFingerprint;
return recordedFingerprint === desiredFingerprint
? { state: "current" }
: { state: "stale" };
}
Expand Down
24 changes: 21 additions & 3 deletions src/integrations/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,18 @@ import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../c
import { isLoopbackHostname } from "../codex/inject";
import type { OcxConfig } from "../types";
import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
import { fingerprint, canonicalContribution, fragmentPathsOf, type OwnershipRecord } from "./ownership";
import { protectedContributionFingerprint, refreshablePathsOf } from "./ownership-policy";
import {
fingerprint,
canonicalContribution,
fragmentPathsOf,
semanticContribution,
type OwnershipRecord,
} from "./ownership";
import {
protectedContributionFingerprint,
refreshablePathsOf,
semanticProtectedContributionFingerprint,
} from "./ownership-policy";
import { createdContainerPaths, mergeContribution, removeFragments } from "./merge";
import { INTEGRATION_CLIENTS, isLoopbackOnly, type IntegrationClientId } from "./registry";
import { classifyIntegration, exportContextOf } from "./state";
Expand Down Expand Up @@ -361,8 +371,13 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo
record: {
clientId, configPath, fileFingerprint: fingerprint(text),
blockFingerprint: fingerprint(canonicalContribution(contribution)),
semanticBlockFingerprint: fingerprint(semanticContribution(contribution)),
...(refreshablePaths.length > 0 ? {
protectedBlockFingerprint: protectedContributionFingerprint(contribution, refreshablePaths),
semanticProtectedBlockFingerprint: semanticProtectedContributionFingerprint(
contribution,
refreshablePaths,
),
refreshablePaths,
} : {}),
fragmentPaths: fragmentPathsOf(contribution), createdContainers: created,
Expand Down Expand Up @@ -556,7 +571,10 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
? (restoredText === null ? "absent" : "conflict")
: !recordDescribesBytes
? "conflict"
: restoredRecord.blockFingerprint === fingerprint(canonicalContribution(fresh))
: (
restoredRecord.semanticBlockFingerprint === fingerprint(semanticContribution(fresh))
|| restoredRecord.blockFingerprint === fingerprint(canonicalContribution(fresh))
)
? "current"
: "stale";

Expand Down
13 changes: 13 additions & 0 deletions structure/09_client-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ added only to a writer would let a mutation bypass the state users saw.
`fileFingerprint` records the exact whole-file result for restore and for serializers that may lose
comments. `blockFingerprint` records the exact generated contribution and detects catalog, model,
port, or provider drift. `fragmentPaths` bounds disable to the paths OpenCodex actually created.
New records pair the exact contribution fingerprints with semantic fingerprints that recursively
sort JSON object keys while preserving array order. Existing records without the semantic companion
fall back to comparing the recorded generated contribution when the catalog has not moved. This
keeps old records readable while preventing a client's formatting-only key reorder from
masquerading as a protected edit.

[Decision Log]
- 목적과 의도: Treat JSON object-key order as formatting while retaining safe ownership proof across upgrades.
- 기존 구현 및 제약 조건: Existing records contain order-sensitive hashes, and replacing their hash format in place would make every installed integration look foreign-edited.
- 검토한 주요 대안: Replace the hash format globally; ignore key order only for ZCode; store a semantic companion beside the existing exact hash.
- 선택한 방식: Preserve the exact hashes for compatibility and add object-key-independent semantic companions to new records, with a bounded desired-contribution fallback for old records.
- 다른 대안 대신 이 방식을 선택한 이유: A global replacement cannot validate old records, while a ZCode-only exception would leave the shared JSON ownership rule inconsistent.
- 장점, 단점 및 영향: New records tolerate key normalization even across catalog refreshes; old records recover when the recorded catalog is still reconstructible, and ambiguous old-record drift remains fail-closed.

Clients normally protect every field in every recorded fragment. A client that writes documented,
runtime-derived fields back into an owned fragment may additionally record:
Expand Down
41 changes: 41 additions & 0 deletions tests/integrations-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PARSE_FAILED, fileIO, loadTarget, parseConfig } from "../src/integratio
import { serializeDocument } from "../src/integrations/serialize";
import {
canonicalContribution,
semanticContribution,
fingerprint,
writeRecord,
type OwnershipRecord,
Expand Down Expand Up @@ -502,6 +503,46 @@ describe("classifier unit behavior", () => {
const reversed = { ...contribution, fragments: [...contribution.fragments].reverse() };
expect(canonicalContribution(reversed)).toBe(canonicalContribution(contribution));
});

test("nested JSON object key order does not change the contribution fingerprint (#2759)", () => {
const original = {
clientId: "zcode" as const,
fragments: [{
path: ["provider", "opencodex"],
value: {
enabled: true,
options: { apiKey: "loopback", baseURL: "http://127.0.0.1:10100/v1" },
models: {
routed: {
modalities: { input: ["text", "image"], output: ["text"] },
limit: { context: 350_000 },
},
},
},
}],
};
const reordered = {
clientId: "zcode" as const,
fragments: [{
path: ["provider", "opencodex"],
value: {
models: {
routed: {
limit: { context: 350_000 },
modalities: { output: ["text"], input: ["text", "image"] },
},
},
options: { baseURL: "http://127.0.0.1:10100/v1", apiKey: "loopback" },
enabled: true,
},
}],
};

expect(semanticContribution(reordered)).toBe(semanticContribution(original));
const reorderedArray = structuredClone(reordered);
reorderedArray.fragments[0]!.value.models.routed.modalities.input = ["image", "text"];
expect(semanticContribution(reorderedArray)).not.toBe(semanticContribution(original));
});
});

describe("ownership is scoped to recorded fragments", () => {
Expand Down
64 changes: 64 additions & 0 deletions tests/integrations-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ function input(overrides: Partial<IntegrationWriteInput> = {}): IntegrationWrite
};
}

function reverseJsonObjectKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(reverseJsonObjectKeys);
if (value === null || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.reverse()
.map(([key, nested]) => [key, reverseJsonObjectKeys(nested)]),
);
}

describe("apply", () => {
test("refuses a client that is not installed, and writes nothing", () => {
const result = applyIntegration(input());
Expand Down Expand Up @@ -216,6 +226,8 @@ describe("apply", () => {

const record = store.readRecords().zcode!;
expect(record.protectedBlockFingerprint).toMatch(/^[0-9a-f]{16}$/);
expect(record.semanticBlockFingerprint).toMatch(/^[0-9a-f]{16}$/);
expect(record.semanticProtectedBlockFingerprint).toMatch(/^[0-9a-f]{16}$/);
expect(record.refreshablePaths).toContainEqual([
"provider", "opencodex", "models", "mystery/model", "limit", "context",
]);
Expand Down Expand Up @@ -251,6 +263,58 @@ describe("apply", () => {
expect(after.provider.opencodex!.models["mystery/model"]!.limit).toBeUndefined();
});

test("ZCode key-order normalization stays refreshable with derived metadata (#2759)", () => {
const configPath = installZcode();
const models: ExportModel[] = [
...MODELS,
{ namespaced: "mystery/model", provider: "mystery", id: "model" },
];
const request = input({ clientId: "zcode", models });
expect(applyIntegration(request).ok).toBe(true);

const document = JSON.parse(readFileSync(configPath, "utf8")) as {
provider: Record<string, { models: Record<string, Record<string, unknown>> }>;
};
document.provider.opencodex!.models["mystery/model"]!.limit = {
context: 128_000,
output: 32_000,
};
document.provider.opencodex!.models["mystery/model"]!.reasoning = { enabled: false };
const reordered = reverseJsonObjectKeys(document);
writeFileSync(configPath, `${JSON.stringify(reordered, null, 2)}\n`);

expect(readIntegrationState(request)).toMatchObject({ state: "stale" });
const refreshed = applyIntegration(request);
expect(refreshed.ok).toBe(true);
if (refreshed.ok) expect(refreshed.changed).toBe(true);
expect(readIntegrationState(request)).toMatchObject({ state: "current" });
});

test("legacy ZCode records tolerate key reordering when the catalog is unchanged (#2759)", () => {
const configPath = installZcode();
const request = input({ clientId: "zcode" });
expect(applyIntegration(request).ok).toBe(true);

const legacy = { ...store.readRecords().zcode! };
delete legacy.semanticBlockFingerprint;
delete legacy.semanticProtectedBlockFingerprint;
store.putRecord(legacy);

const document = JSON.parse(readFileSync(configPath, "utf8")) as {
provider: Record<string, { models: Record<string, Record<string, unknown>> }>;
};
document.provider.opencodex!.models["anthropic/claude-opus-4-8"]!.reasoning = {
enabled: true,
};
writeFileSync(
configPath,
`${JSON.stringify(reverseJsonObjectKeys(document), null, 2)}\n`,
);

expect(readIntegrationState(request)).toMatchObject({ state: "stale" });
expect(applyIntegration(request).ok).toBe(true);
});

test("a legacy ZCode record accepts derived drift only while its generated catalog is unchanged (#2389)", () => {
const configPath = installZcode();
const request = input({ clientId: "zcode" });
Expand Down
Loading