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
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# ObservationEvidence disclosure and digest scope — Phase 4 proposal

> **Status: DRAFT / REVIEW-ONLY / NOT AN APPROVED CONTRACT**
> Parent RFC: [#196](https://github.com/Logorythmus-org/Semantiq/issues/196). Defect: [#192](https://github.com/Logorythmus-org/Semantiq/issues/192). Baseline main: `89cfdcb10ab407f5c1ef84b8cda05894b5dd4a2a`.
> This document is a candidate design for maintainer discussion. It changes no runtime, schema, hash profile registry, release, or historical digest.

## 1. Evidence source and observed risk

`packages/evidence-normalizer/src/types.ts` defines `ObservationEvidence` with context, interpretation, decision, action, result, consequence, provenance, and a string `evidenceDigest`.
In `packages/evidence-normalizer/src/normalizer.ts`, `context.environmentVariables` copies the source environment map and `decision.commandArray` copies raw command arguments. `decision.workingDirectory` and file paths can expose private paths. `result.stdoutSanitized`, `result.stderrSanitized`, `consequence.filesModified[].diffUnified`, and optional `interpretation.rawThoughtLog` undergo pattern-based redaction, which is *not* an exhaustive proof of secrecy. A digest of disclosed secrets does not make disclosure safe; lower-entropy secret values can also be vulnerable to guessing by digest comparison.

The legacy `evidenceDigest` array-replacer preimage fails to bind many nested values. The independent prospective digest test [PR #197](https://github.com/Logorythmus-org/Semantiq/pull/197) does **not** establish a safe disclosure contract. The canonicalization migration matrix classifies this hash surface as `IDENTITY_CRITICAL` / `NOT_SAFE_TO_MIGRATE_YET`.

## 2. Data scopes — proposed, not existing implementation

| Scope | Candidate content | Disclosure and integrity semantics |
| --- | --- | --- |
| `local-raw` | Original execution bundle, if retention is necessary | Restricted local capture; **must not** be exported through a public evidence pathway; no public seal implied |
| `internal-sanitized` | Reviewed redacted observation with authorized fields | Restricted review, provenance and access controls required; exact allowed-field contract and verification are **not yet specified** |
| `public-summary` | An explicit minimum-information projection | Separately identified digest binds **only** the disclosed summary, not a full run or the omitted data |

These are proposed vocabulary values, not registered schema identifiers. Publishing a redacted full `ObservationEvidence` must not be inferred from the presence of text-redaction methods.

## 3. Field candidate matrix for public disclosure

| Observation fields | Candidate public treatment | Reason / review question |
| --- | --- | --- |
| `evidenceId`, `taskId`, `normalizedAt` | Withhold by default; issue a purpose-specific random public correlation ID if authorized | Identity, timing, and correlation can themselves disclose private data |
| `context.environmentVariables` | Withhold values and names by default | Environment maps routinely carry secrets; any allowlist needs reviewed semantics |
| `context.baseImageDigest`, other context | Withhold by default | Image or sandbox identifiers may reveal proprietary state |
| `decision.commandArray`, `workingDirectory` | Withhold entirely by default | Argument values and paths may be secret or personal |
| `action.dispatchedAt`, `timeoutConfiguredMs` | Withhold pending policy | Timing and resource budgets can be sensitive |
| `result.exitCode`, `timedOut`, `oomKilled`, truncated flags | Potentially disclose after security review | Operational outcomes; cannot establish behavioral goal completion |
| `result.executionDurationMs`, `peakMemoryBytes` | Candidate aggregate only; require bounded disclosure | Detailed timing/resources may fingerprint hosts or private workloads |
| `result.stdoutSanitized`, `stderrSanitized` | Withhold text by default | Pattern redaction is insufficient to guarantee privacy |
| `consequence.totalMutationsCount`, `spawnedProcessCount` | Candidate bounded count only | Counts do not prove correct task execution |
| `consequence.filesCreated/Modified/Deleted` | Withhold raw paths, diff text and content hashes by default | Private paths, confidential code and guessable low-entropy content |
| `interpretation.rawThoughtLog`, `structuredIntent` | Withhold by default | Sensitive reasoning and user/agent-provided text |
| `recovery`, `provenance` | Withhold pending separate protocol review | May leak infrastructure and source links |
| `evidenceDigest` | Never recursively include in its own digest subject | Avoid circular preimage; always declare profile and scope |

This matrix is an **unapproved candidate** for discussion, not a guarantee that the listed fields are safe to publish.

## 4. Proposed isolated public-summary fixture

A test-only fixture uses an explicitly supplied **synthetic** `publicRunId` and an allowlisted subset of execution results: `exitCode`, `timedOut`, and `totalMutationsCount`. All private source strings and paths are excluded in the synthetic fixture, and the disclosed subset is self-identified with a fixed domain, a prospective projection identifier, and **an exhaustive positive list of fields committed by the prototype**. That list does not assert anything about unlisted private source data.

Its digest subject conceptually binds:

```text
{
domain: "semantiq.observation.public-summary",
projectionVersion: "proposal-0",
publicRunId: <externally approved opaque non-sensitive ID>,
result: { exitCode, timedOut },
consequence: { totalMutationsCount },
includedFields: ["publicRunId", "result.exitCode", "result.timedOut", "consequence.totalMutationsCount"]
}
```

The **scope is part of the hashed subject**, not only external metadata, to prevent confusing a summary digest with an internal evidence digest. The summary digest is named `semantiq-canonical-json-v1` for serialization, but this alone does not register a product-specific observation profile.

If `exitCode` changes, the public-summary digest must change. If an omitted command argument or environment value changes, this digest is intentionally unchanged. A successful recomputation only shows **digest consistency for the included public-summary fields**. If an attacker can modify the summary and its unhashed/untrusted digest together, they can replace both undetected. Integrity relative to an independent *trusted reference digest*, authenticated registry, or approved digital signature would require a separate trust-anchor design. The prototype does **not** authenticate origin or completeness. It cannot validate raw execution, internal redaction quality, goal success, or omitted values.

## 5. Profile and version verification requirements

- An approved envelope must carry explicit `envelopeVersion`, `digestProfile`, `disclosureScope`, `projectionVersion`, `digest`, and its committed public subject. Exact wire names are not approved.
- Hash with the repository's existing `semantiq-canonical-json-v1` utility on a deterministic, explicitly projected object. Unsupported Unicode, non-integer/negative-zero numeric values, nested `undefined`, cycles, and non-JSON types must fail closed. Do not auto-normalize with lossy JSON round-trips.
- Unknown profile, scope, projection version, malformed digest, or absent metadata cannot produce a verified verdict. Unknown historical origin is `NOT_ESTABLISHED`, not a fallback.
- A distinct immutable historical `observation-top-level-replacer-v0` profile must reproduce original bytes without claiming omitted nested fields were sealed; it is **not** `legacy-ts-v0`.
- Published summaries need an explicit provenance/disclosure statement and security review. A matching hash without an independently trusted digest/signature means **consistency only, not authenticity or tamper resistance against a rewriting attacker**. An authenticated provenance/trust-anchor protocol remains unapproved.
- TS/Python vector parity and historical consumer inventory remain required before any production schema/generation change.
- No silent rewrite of stored `evidenceDigest`, schema `1.0.0`, alpha release identity, or migration-matrix flags.

## 6. Review blockers

1. Establish real consumers, storage paths, old-artifact provenance and verifier APIs for `ObservationEvidence`; a repository-code finding does not establish downstream adoption or public export.
2. Agree on what counts as meaningful and safe operational evidence. Proposed fields may leak workload information and require threat-model acceptance.
3. Specify how `publicRunId` is generated and how authorized reviewers can link it to internal runs without exposing private identifiers.
4. Decide whether a public-summary seal is needed at all and whether the product needs a separate internal-sanitized V1 seal; do not conflate the two.
5. Accept RFC #196 via normal review and obtain CODEOWNER security and Core approval before implementation.

## 7. Acceptance evidence plan

Test-only synthetic cases should demonstrate: omitted synthetic source values absent from the public subject; an included result mutation changes its digest; an omitted private value does not; an exact positive field-inclusion list; the scope and domain cannot be substituted; missing/unknown/malformed profile metadata fails closed; no network or real credentials are involved. The suite cannot establish safety for arbitrary text, external runtime input, or malicious replacement of both summary and digest.

**Scientific boundary:** `Observed ≠ Inferred`; `Absence ≠ Counterevidence`; `Bundle integrity ≠ truth`.
195 changes: 195 additions & 0 deletions tests/unit/observation-disclosure-scope-prototype.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { describe, expect, it } from "vitest";
import {
hashCanonical,
SHARED_CANONICALIZATION_PROFILE
} from "../../packages/sandbox-contracts/src/index.js";

/**
* RFC #196 Phase 4: synthetic PUBLIC-SUMMARY privacy contract sketch.
*
* Test-only. This is intentionally NOT a whole ObservationEvidence digest,
* an approved disclosure policy, or an authorized production verifier.
*/
const DISCLOSURE_SCOPE = "public-summary" as const;
const PROJECTION_VERSION = "proposal-0" as const;
const DOMAIN = "semantiq.observation.public-summary" as const;
const INCLUDED_FIELDS = [
"publicRunId",
"result.exitCode",
"result.timedOut",
"consequence.totalMutationsCount"
] as const;

interface PrivateFixture {
publicRunId: string;
context: { environmentVariables: Record<string, string> };
decision: { commandArray: string[]; workingDirectory: string };
interpretation: { rawThoughtLog: string };
result: {
exitCode: number;
timedOut: boolean;
stdoutSanitized: string;
stderrSanitized: string;
};
consequence: {
totalMutationsCount: number;
filesModified: { path: string; diffUnified: string }[];
};
}

function sample(): PrivateFixture {
return {
publicRunId: "synthetic-public-run-01",
context: { environmentVariables: { PRIVATE_TOKEN: "fixture-sensitive-env" } },
decision: {
commandArray: ["runner", "--token=fixture-sensitive-arg"],
workingDirectory: "/private/fixture-directory"
},
interpretation: { rawThoughtLog: "fixture-sensitive-reasoning" },
result: {
exitCode: 0,
timedOut: false,
stdoutSanitized: "fixture-sensitive-output",
stderrSanitized: "fixture-sensitive-error"
},
consequence: {
totalMutationsCount: 1,
filesModified: [{ path: "/private/fixture-file", diffUnified: "fixture-sensitive-diff" }]
}
};
}

function publicSubject(observation: PrivateFixture) {
// Explicit projection; never spread raw/private input into public evidence.
return {
domain: DOMAIN,
projectionVersion: PROJECTION_VERSION,
publicRunId: observation.publicRunId,
result: {
exitCode: observation.result.exitCode,
timedOut: observation.result.timedOut
},
consequence: {
totalMutationsCount: observation.consequence.totalMutationsCount
},
includedFields: INCLUDED_FIELDS
};
}

function sealPublicSummary(observation: PrivateFixture) {
const subject = publicSubject(observation);
const digest = hashCanonical(subject, {
profile: SHARED_CANONICALIZATION_PROFILE
}).sha256;

return {
envelopeVersion: "proposal-0",
digestProfile: SHARED_CANONICALIZATION_PROFILE,
disclosureScope: DISCLOSURE_SCOPE,
projectionVersion: PROJECTION_VERSION,
subject,
digest: `sha256:${digest}`
};
}

type PublicEnvelope = ReturnType<typeof sealPublicSummary>;

function verifyPublicSummary(envelope: PublicEnvelope): boolean {
if (
envelope.envelopeVersion !== "proposal-0" ||
envelope.digestProfile !== SHARED_CANONICALIZATION_PROFILE ||
envelope.disclosureScope !== DISCLOSURE_SCOPE ||
envelope.projectionVersion !== PROJECTION_VERSION ||
envelope.subject.domain !== DOMAIN ||
envelope.subject.projectionVersion !== PROJECTION_VERSION
) {
throw new TypeError("Unknown or inconsistent summary profile/scope/version");
}
if (!/^sha256:[a-f0-9]{64}$/.test(envelope.digest)) {
throw new TypeError("Malformed summary digest");
}
const computed = hashCanonical(envelope.subject, {
profile: SHARED_CANONICALIZATION_PROFILE
}).sha256;
return envelope.digest === `sha256:${computed}`;
}

describe("RFC #196 Phase 4 — isolated public summary disclosure (test-only)", () => {
it("never includes synthetic private fields in the public summary subject", () => {
const subjectJson = JSON.stringify(sealPublicSummary(sample()));
for (const privateValue of [
"fixture-sensitive-env",
"fixture-sensitive-arg",
"fixture-sensitive-reasoning",
"fixture-sensitive-output",
"fixture-sensitive-error",
"fixture-sensitive-diff",
"/private/fixture"
]) {
expect(subjectJson).not.toContain(privateValue);
}
expect(subjectJson).toContain("public-summary");
expect(subjectJson).toContain("result.exitCode");
});

it("binds exactly the disclosed operational outcome, not hidden source data", () => {
const original = sample();
const changedResult = {
...original,
result: { ...original.result, exitCode: 1 }
};
const changedWithheld = {
...original,
context: { environmentVariables: { PRIVATE_TOKEN: "different-private-value" } },
decision: {
commandArray: ["runner", "--token=different-private-arg"],
workingDirectory: "/different-private-path"
}
};

const originalDigest = sealPublicSummary(original).digest;
const changedResultDigest = sealPublicSummary(changedResult).digest;
const changedWithheldDigest = sealPublicSummary(changedWithheld).digest;

expect(changedResultDigest).not.toBe(originalDigest);
// Intentional: this is a public-subset seal, NOT full evidence integrity.
expect(changedWithheldDigest).toBe(originalDigest);
});

it("rejects unknown versions and scopes rather than falling back", () => {
const envelope = sealPublicSummary(sample());
expect(verifyPublicSummary(envelope)).toBe(true);

const changedScope = { ...envelope, disclosureScope: "internal" };
const changedProfile = { ...envelope, digestProfile: "legacy-ts-v0" };
const changedVersion = { ...envelope, projectionVersion: "proposal-999" };
const malformed = { ...envelope, digest: "sha256:bad" };

expect(() => verifyPublicSummary(changedScope as PublicEnvelope)).toThrow(TypeError);
expect(() => verifyPublicSummary(changedProfile as PublicEnvelope)).toThrow(TypeError);
expect(() => verifyPublicSummary(changedVersion as PublicEnvelope)).toThrow(TypeError);
expect(() => verifyPublicSummary(malformed)).toThrow("Malformed summary digest");
});

it("detects tampering of included summary fields", () => {
const envelope = sealPublicSummary(sample());
const changedSubject = {
...envelope.subject,
consequence: { totalMutationsCount: 7 }
};
const changedEnvelope = { ...envelope, subject: changedSubject };

expect(verifyPublicSummary(changedEnvelope)).toBe(false);
});

it("rejects unsupported numeric values without coercing them into JSON", () => {
const original = sample();
for (const unsafeValue of [1.5, Number.NaN, -0, Number.POSITIVE_INFINITY]) {
const mutated = {
...original,
result: { ...original.result, exitCode: unsafeValue }
};
expect(() => sealPublicSummary(mutated)).toThrow(TypeError);
}
});
});
Loading