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
125 changes: 111 additions & 14 deletions src/codex/inject-coordination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
import type {
CodexArtifactId,
CodexProvenanceEntry,
CodexProvenanceLedger,
} from "./convergence-types";
import {
codexWriteCoordination,
Expand Down Expand Up @@ -196,6 +197,41 @@ export function captureCodexPreImages(): CodexPreImages {
*/
export const CODEX_PROVENANCE_MAX_TRANSACTIONS = 16;

/**
* How many serialized bytes of evidence the ledger keeps.
*
* The transaction window alone bounds the ledger only if each transaction is small, and a
* baseline embeds the artifact's exact bytes. Those artifacts sit outside this proxy's trust
* boundary: a native `config.toml` grown to hundreds of megabytes is copied into the record as
* base64 and re-serialized on every append, so a single oversized file turns a 16-transaction
* window into a multi-gigabyte write.
*
* The size is derived from the window above rather than picked: the 25 KB `config.toml` that
* comment describes measures 100.8 KiB per transaction, so a full 16-transaction window is
* 1.58 MiB. 4 MiB leaves that ordinary window untouched with room to spare while still refusing
* the pathological case, and a regression test pins the ordinary window so the two cannot drift
* apart silently.
*/
export const CODEX_PROVENANCE_MAX_BYTES = 4 * 1024 * 1024;

/**
* Measure the representation the integration-record writer actually emits.
*
* The minimal wrapper reproduces the final indentation depth of `provenance.entries`, including
* structured unknown extension fields preserved from older records. Its fixed wrapper makes this
* slightly conservative as a ledger-only ceiling, which is preferable to admitting an entry that
* expands past the limit when `JSON.stringify(record, null, 2)` writes it.
*/
function serializedProvenanceBytes(
entries: readonly CodexProvenanceEntry[],
ledger: CodexProvenanceLedger | undefined,
): number {
return Buffer.byteLength(`${JSON.stringify({
version: 1,
provenance: { ...ledger, entries },
}, null, 2)}\n`, "utf-8");
Comment thread
luvs01 marked this conversation as resolved.
}

function provenanceBaseline(bytes: string | null): CodexProvenanceEntry["baseline"] {
if (bytes === null) return { kind: "absent" };
return {
Expand All @@ -214,21 +250,66 @@ function provenancePostImage(path: string): string | null {
}

/**
* Keep the newest `CODEX_PROVENANCE_MAX_TRANSACTIONS` transactions, whole.
* Keep the newest transactions that fit both the transaction window and the byte ceiling, whole.
*
* Trimming by ENTRY count would cut a transaction in half and leave evidence that says a
* transaction touched two artifacts when it touched three — worse than dropping it outright,
* because a partial record still reads as complete. Order is preserved; only whole leading
* transactions are removed.
* because a partial record still reads as complete. Order is preserved; only whole transactions
* are removed.
*
* The byte ceiling applies the same rule: a transaction that does not fit is omitted whole,
* including the newest one — a record silently truncated to fit would be read as a faithful
* pre-image. An oversized transaction is skipped rather than ending the scan, so one pathological
* artifact cannot erase the smaller transactions that still fit and are still diagnosable.
*/
export function boundProvenanceEntries(
entries: readonly CodexProvenanceEntry[],
maxTransactions = CODEX_PROVENANCE_MAX_TRANSACTIONS,
maxBytes = CODEX_PROVENANCE_MAX_BYTES,
ledger?: CodexProvenanceLedger,
): readonly CodexProvenanceEntry[] {
const order: string[] = [];
for (const entry of entries) if (!order.includes(entry.txId)) order.push(entry.txId);
if (order.length <= maxTransactions) return entries;
const keep = new Set(order.slice(order.length - maxTransactions));
const transactions = new Map<string, CodexProvenanceEntry[]>();
for (const entry of entries) {
const transaction = transactions.get(entry.txId);
if (transaction) transaction.push(entry);
else transactions.set(entry.txId, [entry]);
}
const order = [...transactions.keys()];
if (order.length <= maxTransactions) {
const baselineBytes = entries.reduce(
(total, entry) => total + (entry.baseline.kind === "present" ? entry.baseline.bytesBase64.length : 0),
0,
);
if (
baselineBytes <= maxBytes
&& serializedProvenanceBytes(entries, ledger) <= maxBytes
) return entries;
}
// Newest first, so the transactions anyone diagnoses against are the ones that fit.
const keep = new Set<string>();
let baselineBytes = 0;
for (let i = order.length - 1; i >= 0 && keep.size < maxTransactions; i--) {
const txId = order[i]!;
const txEntries = transactions.get(txId)!;
// Measure the embedded pre-images first: a pathological baseline is refused without
// serializing it, so the ceiling does not itself allocate the payload it exists to reject.
const transactionBaselineBytes = txEntries.reduce(
(total, entry) => total + (entry.baseline.kind === "present" ? entry.baseline.bytesBase64.length : 0),
0,
);
if (baselineBytes + transactionBaselineBytes > maxBytes) continue;
const candidateKeep = new Set(keep);
candidateKeep.add(txId);
// Reuse the already-grouped transactions: this avoids another full-ledger filter on each
// iteration while preserving the original transaction and entry order.
const candidateEntries = order.flatMap(id =>
candidateKeep.has(id) ? transactions.get(id)! : []
);
if (serializedProvenanceBytes(candidateEntries, ledger) > maxBytes) continue;
Comment thread
luvs01 marked this conversation as resolved.
Comment thread
luvs01 marked this conversation as resolved.
baselineBytes += transactionBaselineBytes;
keep.add(txId);
}
if (keep.size === order.length) return entries;
return entries.filter(entry => keep.has(entry.txId));
}

Expand All @@ -250,13 +331,29 @@ export function recordCodexNativeTransactionProvenance(
txId,
at,
}));
return updateIntegrationRecord(record => ({
...record,
provenance: {
...record.provenance,
entries: boundProvenanceEntries([...(record.provenance?.entries ?? []), ...entries]),
},
}));
return updateIntegrationRecord(record => {
const previousLedger = record.provenance;
// Unknown ledger extensions are forward-compatible and must be preserved. If those fixed
// fields alone exceed the ceiling, no entry selection can make the write compliant. Keep the
// existing record byte-for-byte instead of deleting all known evidence and rewriting the
// same oversized extension on every native transaction.
if (
previousLedger
&& serializedProvenanceBytes([], previousLedger) > CODEX_PROVENANCE_MAX_BYTES
) return record;
return {
...record,
provenance: {
...previousLedger,
entries: boundProvenanceEntries(
[...(previousLedger?.entries ?? []), ...entries],
CODEX_PROVENANCE_MAX_TRANSACTIONS,
CODEX_PROVENANCE_MAX_BYTES,
previousLedger,
),
},
};
});
}

/**
Expand Down
14 changes: 12 additions & 2 deletions src/codex/integration-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,17 @@ function mergeEntry(previous: CodexProvenanceEntry, next: CodexProvenanceEntry):
function mergeLedger(previous: CodexProvenanceLedger, next: CodexProvenanceLedger): CodexProvenanceLedger {
const unused = new Set(previous.entries.map((_, index) => index));
const entries = next.entries.map((entry, nextIndex) => {
let previousIndex = previous.entries.findIndex((candidate, index) =>
// Extensions follow provenance identity, never position. A bounded ledger may drop an
// oversized transaction, shifting an unrelated entry into that slot, and even within one
// transaction and timestamp the artifact may differ. Since this search already requires
// txId, timestamp, and artifact identity, any remaining positional match is by definition
// a different artifact, so preserving its unknown entry/artifact/baseline fields would
// attach false evidence and could regrow the record past the measured byte ceiling.
const previousIndex = previous.entries.findIndex((candidate, index) =>
unused.has(index)
&& candidate.txId === entry.txId
&& candidate.at === entry.at
&& knownArtifactIdentity(candidate.artifact) === knownArtifactIdentity(entry.artifact));
if (previousIndex < 0 && unused.has(nextIndex)) previousIndex = nextIndex;
if (previousIndex < 0) return entry;
unused.delete(previousIndex);
return mergeEntry(previous.entries[previousIndex]!, entry);
Expand Down Expand Up @@ -245,6 +250,11 @@ export const updateIntegrationRecord = (
if (read.kind === "invalid") return read;
const previous: CodexIntegrationRecord = read.kind === "ready" ? read.record : { version: 1 };
const proposed = mutate(previous);
// The mutator can explicitly decline an update by returning the exact record it received.
// This matters when a preserved forward-compatible extension already exceeds a caller's
// write budget: rewriting the same oversized bytes would amplify I/O while deleting known
// entries cannot make that irreducible overhead fit.
if (proposed === previous) return { kind: "updated" as const, record: previous };
if (!validateRecord(proposed)) {
return { kind: "invalid", message: "Codex integration record update produced a malformed v1 shape" };
}
Expand Down
Loading
Loading