[codex] Add provenance audit observations#5065
Conversation
There was a problem hiding this comment.
Approving. Right shape for the protocol: audit observations are a separate surface from rejection codes, and keeping OVERSIGHT_DISCLOSURE_CARVEOUT_CLAIMED out of error-code.json preserves the "errors are rejection signals" invariant that file enforces.
Things I checked
static/schemas/source/creative/get-creative-features-response.json:32-38—audit_observationsis optional on the success branch; the branch has noadditionalProperties: false, so legacy senders/receivers are unaffected.minorchangeset is correct.static/schemas/source/creative/audit-observation.json— closed shape (additionalProperties: false) at root,details, andclaimed_value.extis the only extension surface. Internally consistent with the MDX examples and the storyboard payload (code,severity: audit-worthy,recovery: informationalspelled the same in all six occurrence sites).- Code/observation separation: it's an observation, not an error. Folding it into
error-code.jsonwould have eroded the invariant. Right call. - Working-group alignment: faithful to the
docs/creative/provenance.mdx:160Warning on EU AI Act Article 50(4) editorial responsibility, and tocore/provenance.json:32-34framing thathuman_oversightanddisclosure.requiredare independent fields. The protocol surface here matches what the corpus already committed to. - Storyboard validation primitives:
field_valuewithallowed_valuesandupstream_trafficwithpayload_must_containare both documented instatic/compliance/source/universal/storyboard-schema.yamland used in prior scenarios. Real primitives, not invented. scripts/audit-oneof.mjsimpact: the new object is a single closed shape, no newoneOfintroduced. Walker is not affected.
Follow-ups (non-blocking — file as issues)
- Allowlist "mirror" is a constrained subset.
audit-observation.json:39andprovenance-verification.mdx:167describe thedetailsshape as mirroring thePROVENANCE_CLAIM_CONTRADICTEDallowlist. The mirror's field set matches, butclaimed_valuehere is a closed object (human_oversightenum +disclosure_required: const false) where the contradiction allowlist treatsclaimed_valueas free-form. Reword to "constrained subset" so emitters don't expect to pass arbitraryclaimed_valuepayloads. - Required-set drift between docs and schema.
docs/governance/creative/get_creative_features.mdx:154andprovenance-verification.mdx:167advertise the allowlist as the full six-field tuple.audit-observation.json:84only requiresagent_url, claimed_value—feature_id,observed_value,confidence,substituted_forare optional. A conformant emitter could legally ship only the two required fields and still surprise a buyer who read the docs. Either tightenrequiredon the schema for the "when applicable" fields or soften the doc copy from "details limited to {…}" to "details limited to a subset of {…}." - Forward-compat smell on the second observation code.
codeis a one-value enum ataudit-observation.json:8-14andclaimed_valueis shaped specifically for that code. The next code will force a refactor tooneOfkeyed oncode(or a looserclaimed_value). Worth a one-line note in the schema description that the shape MAY become aoneOfoncodein a future minor, so adopters don't write parsers that assume the v1 shape is permanent.
Minor nits (non-blocking)
upstream_trafficendpoint pattern is too loose.static/compliance/source/protocols/media-buy/scenarios/provenance_audit_observation.yaml:192usesendpoint_pattern: "POST *"which matches any outbound POST. Tighten to"POST *governance.encypher.seller.example*"so the check actually asserts the seller called the on-list verifier instead of any URL.- Storyboard fixture asset must exist.
provenance_audit_observation.yaml:150referenceshttps://test-assets.adcontextprotocol.org/acme-outdoor/ai-generated-true.jpg. Confirm that asset is published or runners that fetch it will fail. Notable for a sample that nominally exercises AI-generated content — the file name is the only thing carrying the "AI" semantics in the fixture. - PR title prefix.
[codex]is not conventional-commits; the squash commit will rewrite tofeat(provenance): add audit observationsper the existing convention so this is squash-safe, but worth fixing the title before merge for the PR-list readability.
LGTM. Follow-ups noted below.
|
provenance audit observations are the right primitive for creative governance. the underlying question: can the observation itself be tamper-evident? if a governance finding says "this creative claims human oversight but disclosure.required is false," that finding should be independently verifiable. a signed observation means a buyer can trust the audit result without trusting the audit platform. we built this receipt layer for AI agent actions. Ed25519 signed, JCS canonical (RFC 8785), hash-linked chains. the same pattern applies to creative provenance: each audit observation generates a signed receipt with the finding, the policy version, and the creative hash. github.com/arian-gogani/nobulex |
3fd5a4e to
d2627dd
Compare
There was a problem hiding this comment.
LGTM. Additive surface only — new optional audit_observations[] field, new shared schema with one enumerated code, new sandbox-only controller scenario behind the 3.0-compat gate. minor changeset is the right call.
The shape is right: OVERSIGHT_DISCLOSURE_CARVEOUT_CLAIMED is claim-driven, not verifier-driven, and the schema enforces that via claimed_value.disclosure_required: const false + human_oversight ∈ {edited, directed}. Mirrors the PROVENANCE_CLAIM_CONTRADICTED audit-safe allowlist (agent_url, feature_id, claimed_value, observed_value, confidence, substituted_for) — same closed-allowlist defense as the contradiction error path. Audit observation is non-rejection by construction; the storyboard's field_absent: creatives[0].errors[0].code validations lock that contract in.
Things I checked
enforceProvenancePolicyreturn-shape refactor fromTaskError | null→{ error, auditObservations }propagated to every structural-error path (task-handlers.ts:1454-1574) and to the single success site inhandleSyncCreatives(task-handlers.ts:4015-4019), with set/delete branches keyed oncreativeId.- Tenant isolation preserved — sessions keyed via
sessionKeyFromArgs, observations attached tosession.complyExtensions.provenanceAuditObservations. No cross-session leak surface. - 3.0-compat gating closed in all three places:
comply-test-controller.ts:885-892,localScenariosFor, andtenants/router.ts:298-311. - Schema-vs-docs coherence: field table in
get_creative_features.mdx, the Article 50(4) carve-out prose inprovenance.mdx:160, and the verifier walkthrough inprovenance-verification.mdx:139-167all converge on the same trigger condition and the same flattened-alias semantics. No drift. oneOfdisjointness oncomply-test-controller-response.json—ProvenanceAuditObservationsSuccessis uniquely identified bycreative_id+audit_observationsin itsrequiredset; positive disjointness holds even though onlySeedSuccess.not.anyOfgot the new field added (see follow-up).- Storyboard
provenance_audit_observation.yamlexercises bothdirectedandeditedpaths, asserts the verifier was actually invoked viacheck: upstream_trafficwithpayload_must_containonhuman_oversightanddisclosure.required, then asserts the recorded observation through the sandbox-only controller — not through a public seller response. Right separation. - Real test exercise, not mocked:
comply-test-controller.test.ts:181-274rides throughsync_creatives→enforceProvenancePolicy→runProvenanceVerifier→handleGetCreativeFeatures.
Follow-ups (non-blocking — file as issues)
oneof-discriminators.baseline.jsonis stale. Variants moved 7 → 8 but the baseline note still readsvariants: 7.audit-oneof.mjs --checkpasses (no kind/key regression), so this is not a gate failure — but refresh vianode scripts/audit-oneof.mjs --updatebefore the baseline drifts further.- Partial
not.anyOfextension.audit_observationswas added toSeedSuccess.not.anyOf(line 360) but not the other sibling variants. Disjointness holds via required-set distinctness, but the file's convention is to enumerate all sibling discriminator markers in every variant. Extend for consistency. - Behavior change at
task-handlers.ts:5670-5703. Whendigital_source_typeis not a string,runProvenanceVerifierno longer short-circuits before verifier selection — it now runs the in-processhandleGetCreativeFeaturescall before short-circuiting. In-process is cheap; if a real seller wires this to a network call, the cost profile shifts. Intentional, since the carve-out observation is independent of DST, but worth a sentence in the PR body or a one-line comment naming the intent.
Minor nits (non-blocking)
- Test brittleness.
comply-test-controller.test.ts:268-274dereferencesobservations[0].codewithout first checkingobservations.length. Addexpect(observations.length).toBeGreaterThan(0)so a regression to[]produces a readable failure instead of aTypeError. - Three-place restatement. The same trigger rule for
OVERSIGHT_DISCLOSURE_CARVEOUT_CLAIMEDis stated inprovenance.mdx, theget_creative_features.mdxparagraph below the field table, andprovenance-verification.mdx. The middle one can compress to one sentence — the table above it already carries the load. agent_urloverride inrunProvenanceVerifier(task-handlers.ts:5710-5717) intentionally rewritesdetails.agent_urltochosen.agent_urlover whatever the in-process handler returned. One-line comment that the public response carries the seller's chosen on-list verifier, not the local handler's identity, would save a reader the trace.
LGTM. Follow-ups noted below.
d2627dd to
f3e2ecb
Compare
There was a problem hiding this comment.
LGTM. Clean additive minor: new optional audit_observations[] on a success response, new opt-in query_provenance_audit_observations controller scenario, audit-safe details allowlist mirrors PROVENANCE_CLAIM_CONTRADICTED exactly. The right shape.
Things I checked
- Scenario enum agreement across all eight call sites (LOCAL_SCENARIOS, SALES_CURRENT_SCENARIOS, tenant-smoke, get_adcp_capabilities handler, request/response schemas, capabilities-response, two test assertions) — consistent, no drift.
- 3.0 compat gating: scenario is filtered out in
localScenariosFor(comply-test-controller.ts:769), intryHandleLocalComplyScenario(tenants/router.ts:301-311), and in the capabilities handler (task-handlers.ts:4562). Handler also returnsUNKNOWN_SCENARIOif called under 3.0 (comply-test-controller.ts:885-893). Fail-closed in all three paths. enforceProvenancePolicyreturn-shape refactor: every early-return path returns{ error, auditObservations: [] }— six rewrite sites attask-handlers.ts:1468, 1480, 1495, 1503, 1513, 1544. All accounted for.runProvenanceVerifiercorrectly stampsagent_urltochosen.agent_urland only addssubstituted_forwhen the buyer URL was off-list (task-handlers.ts:5705-5712). Off-list buyer URLs are rejected at step 5 withPROVENANCE_VERIFIER_NOT_ACCEPTEDbefore observations are recorded — sosubstituted_forin a persisted observation is unreachable from buyer-controlled content.- Schema-vs-docs coherence:
get_creative_features.mdxandprovenance-verification.mdxfield tables matchaudit-observation.jsonfield names, optionality, enum values, and theclaimed_valueshape. Changeset isminor, which matches the additive wire impact. disclosure_required: const falselock +human_oversightenum['edited','directed']— the trigger surface and the schema are pinned together.- Session isolation: writer (
task-handlers.ts:4015-4019) and reader (comply-test-controller.ts:1662-1694) use the samesessionKeyFromArgsderivation. No cross-session lookup. Per-session Map bounded byMAX_CREATIVES_PER_SESSION = 500.security-reviewer: no High/Medium findings, safe to merge. ad-tech-protocol-expert: sound-with-caveats (asymmetricnot.anyOfreciprocity on sibling variants — see follow-up).code-reviewer: no blockers, internal-consistency clean.- Backwards-compat on
deserializeSession:asMapfallback covers sessions persisted before this field existed (state.ts:395).
Follow-ups (non-blocking — file as issues)
not.anyOfreciprocity is one-sided. SeedSuccess was extended to excludeaudit_observations(comply-test-controller-response.json:360), but the other six sibling variants (ListScenariosSuccess, StateTransitionSuccess, SimulationSuccess, ForcedDirectiveSuccess, UpstreamTrafficSuccess, ControllerError) were not. Not a real collision today —ProvenanceAuditObservationsSuccessrequirescreative_id, which no other variant carries, so the keyset can't double-match — but the file's established convention is full mutex. Tighten in a follow-up to keep the discriminator pattern uniform.- Test coverage gap.
query_provenance_audit_observationshas happy-path coverage only (comply-test-controller.test.ts:194-269). TheNOT_FOUNDbranch and the "creative exists, no observations recorded" empty-array branch are documented behaviors with no tests. Add sibling cases. Map<string, unknown[]>under-constrains the writer.types.ts:255typesprovenanceAuditObservationsasunknown[]; the concrete shape isCreativeAuditObservation[]declared insidetask-handlers.ts:5551. Export the interface and tighten the type to prevent a future writer from storing arbitrary buyer payloads (defense-in-depth — the current single writer is safe).- Audit observations survive
force_creative_purgehard-purge by design. Thecomply-test-controller.ts:1681dual-check (session.creatives.has || provenanceAuditObservations.has) accommodates this. Worth a one-line comment naming the intent so a future reader doesn't "tighten" it.
Minor nits (non-blocking)
- Helper to compress the six-site rewrite.
enforceProvenancePolicy's early returns are now six near-identical{ error: provenanceError(...), auditObservations: [] }blocks (task-handlers.ts:1468-1544). AprovenanceFailure(...)wrapper would cut the noise. Style only. - Behavior change worth flagging in code.
runProvenanceVerifiernow calls the verifier even when there's nodigital_source_typeclaim, where it used to early-return (task-handlers.ts:5680). Intentional — the audit-observation surface needs the verifier call regardless of DST — but a one-line comment would prevent a future reader from "fixing" the order back.
Approved.
f3e2ecb to
07968b5
Compare
There was a problem hiding this comment.
LGTM. Follow-ups noted below. Additive schema change with a clean disjointness ratchet — new optional field on get_creative_features, new opt-in scenario, new oneOf variant whose disjointness keys on audit_observations, and a sandbox-only controller hook that keeps the internal audit log off public seller responses. The seller/verifier split (audit_observations recorded against session.complyExtensions, queried only via comply_test_controller) is the right shape.
Things I checked
enforceProvenancePolicyrefactor returns{error, auditObservations}at every callsite. Six early-return branches and the contradiction path all forwardauditObservations: []correctly; the contradiction + success paths forward what the verifier produced.server/src/training-agent/task-handlers.ts:1448-1572.- Persistence is gated on
!isDryRunand usesset/deletefor stale-state hygiene.task-handlers.ts:4015-4019. - The new oneOf variant in
static/schemas/source/compliance/comply-test-controller-response.json:386-454is mutually disjoint from all 7 existing variants — every existing variant now addsaudit_observationsto itsnot.anyOf, and the new variant excludes itself from the other six discriminators. scripts/oneof-discriminators.baseline.json:99-103legitimately ratchets from 7→8 variants withaudit_observationsas the new discriminator — this is a real new shape, not a walker bypass.- Changeset is
.changeset/4438-provenance-audit-observations.mdwithminor. Correct: additive optional field, additive enum value behind the existing "MUST accept unknown scenario strings" guard. - 3.0 compat gating is consistent across
comply-test-controller.ts,tenants/router.ts,tenant-smoke.test.ts, andtask-handlers.ts(handleGetAdcpCapabilitiesdoes not advertise the scenario under 3.0). - Storyboard
upstream_trafficassertions key oncreative_manifest.provenance.*— the canonical wire path, portable across implementations. - Docs/schema coherence:
OVERSIGHT_DISCLOSURE_CARVEOUT_CLAIMED, thecreative_manifest.provenance.disclosure.requiredpath, and theaudit-worthy/informationalenums appear identically across the schema and all three docs files.
Follow-ups (non-blocking — file as issues)
- Hard purge leaks audit observations.
server/src/training-agent/comply-test-controller.ts:1426handleForceCreativePurgecallssession.creatives.delete(creativeId)without mirroring the delete onsession.complyExtensions.provenanceAuditObservations. The new query handler's fallback (||against the audit map) will then return observations for a creative that no longer exists in the sandbox instead ofNOT_FOUND. Mirror the delete on hard purge. - Verifier now runs for every creative with
accepted_verifiers, even when there's no provenance object at all.task-handlers.ts:5670-5705. Not buggy —buildOversightDisclosureAuditObservationsshort-circuits on missing provenance — but aif (!provenance) return { contradiction: null, auditObservations: [] };guard before the in-process verifier call restores the previous behavior for non-provenance creatives. - Negative-path tests missing on
query_provenance_audit_observations.server/tests/unit/comply-test-controller.test.tscovers the happy path but notINVALID_PARAMS(missingparams, emptycreative_id),NOT_FOUND(creative never synced), or the 3.0-compatUNKNOWN_SCENARIOrejection. Four error branches unreached. recovery: informationalis a vocabulary fork. Canonical error-code recovery enum is{correctable, transient, terminal}. The new value is intentional — observations aren't errors — but worth a one-line callout inaudit-observation.jsonto keep future implementers from conflating the two surfaces.claimed_valuemirror claim is loose.audit-observation.json:39saysdetailsmirrorsPROVENANCE_CLAIM_CONTRADICTED's allowlist, but the typing differs: CONTRADICTED carries a string DST, the new observation carries a typed object. The docs flag the flattened-alias note, but reusing the field name across two shapes will trip naive serializers expectingtypeof claimed_value === 'string'.
Minor nits (non-blocking)
- Path-prefix drift between docs.
docs/governance/creative/get_creative_features.mdx:155writescreative_manifest.provenance.disclosure.required;docs/governance/creative/provenance-verification.mdx:160writesprovenance.disclosure.required. Both are correct in context. Unify.
Approved.
07968b5 to
f061d3d
Compare
There was a problem hiding this comment.
LGTM. Clean additive feature with disciplined surface design — claim-driven observation that fires on the success branch, no rejection coupling.
Things I checked
audit-observation.jsonshape —code/severity/recoverytriple withrecovery: "informational"deliberately disjoint from the error-code recovery enum. Right call. Audit observations live on the success branch; reusing the error-recovery vocabulary would have conflated "fix this to proceed" with "we noted this."additionalProperties: falseat envelope +detailscloses the verifier-leak gapprovenance-verification.mdx:167warns against.detailsallowlist{ agent_url, feature_id, claimed_value, observed_value, confidence, substituted_for }mirrors thePROVENANCE_CLAIM_CONTRADICTEDallowlist atdocs/governance/creative/provenance-verification.mdx:133exactly. Same audit-safe story.- oneOf hygiene on
comply-test-controller-response.json: all 8 branches mutually disjoint. NewProvenanceAuditObservationsSuccesscarries every sibling-discriminator key in itsnot.anyOf(lines 421-451), and every sibling addedaudit_observationsto theirs. Baseline ratchet 7→8 atscripts/oneof-discriminators.baseline.json:99-103is intentional. - Changeset type
minoris correct — optional field added to a success branch, new oneOf branch with full disjointness, additive scenario enum value (open-for-extension already documented atcomply-test-controller-response.json:51). runProvenanceVerifierreordering atserver/src/training-agent/task-handlers.ts:5663-5746. Newif (!provenance)guard at 5673 prevents calling the verifier on creatives with no provenance object.PROVENANCE_CLAIM_CONTRADICTEDpath preserved —typeof claimed !== 'string'still short-circuits at 5723 before any contradiction check. Claim-driven audit observations now fire even when DST is absent. Right shape.enforceProvenancePolicyreturn-type change to{ error, auditObservations }. Every return site converted (1458, 1472, 1483, 1496, 1503, 1515, 1547, 1572, 1575). Caller at task-handlers.ts:3960 consumes both fields and persists with explicit set-or-delete (4015-4019) so re-sync of a non-audit creative clears any prior observation.- 3.0 compat gating consistent across four sites: handler (
comply-test-controller.ts:885-893),localScenariosForfilter (769), tenants router (298-313),get_adcp_capabilitiesexposure (task-handlers.ts:4562). No path where a 3.0 client gets a success on one route andUNKNOWN_SCENARIOon another. - Hard purge wires
session.complyExtensions.provenanceAuditObservations.delete(creativeId)atcomply-test-controller.ts:1427. Soft purge leaves observations in place — matches the tombstone semantics. - Storyboard validates the three observable contracts: accept-not-reject (
creatives[N].action ∈ {created, updated}+field_absentonerrors[0].code), outbound verifier call (upstream_trafficwithpayload_must_containon the carve-out fields), sandbox controller assertion viaquery_provenance_audit_observations. Does not require the seller to echo the audit observation on the publicsync_creativesresponse — that boundary is respected.
Follow-ups (non-blocking — file as issues)
runProvenanceVerifiernow callshandleGetCreativeFeaturesfor everysync_creativeswithprovenance+accepted_verifiers, even whendigital_source_typeis absent. Previously short-circuited. Intentional — claim-driven observations need the verifier path — but it's an additive hot-path cost on every provenance-carrying sync. Worth a perf note in the handler comment so a future reader doesn't "fix" it back.task-handlers.ts:5715-5722overridesdetails.agent_urlafter spreading the inner observation, and conditionally addssubstituted_for. TodayhandleGetCreativeFeaturesnever setssubstituted_forso this works, but if the inner handler ever starts emitting one, the conditional add silently shadows it. A defensive merge (substituted_for: substituted ?? observation.details.substituted_for) or a one-line comment pinning the assumption would future-proof.
Minor nits (non-blocking)
- Baseline catch-up bundled with the schema change.
scripts/oneof-discriminators.baseline.jsonwas created in62af2ccand shipped stale relative toacquire-rights-response(status→rights_status),creative-approval-response(status→approval_status),verify-brand-claim-response(status→verification_status), andaudience-selector(signal_idremoval). All four are catch-up from prior merges, not changes authored here. Worth a note: a baseline that needs four unrelated regenerations one merge after creation is something to keep an eye on.
Approved.
Summary
get_creative_features.audit_observations[]success-field for governance findings that should be visible to buyers without rejecting creative.OVERSIGHT_DISCLOSURE_CARVEOUT_CLAIMED, for AI-assisted provenance that claims edited/directed human oversight whiledisclosure.requiredis false.query_provenance_audit_observationscan assert that the verifier actually emittedaudit_observations[].Closes #4438.
Validation
npm run build:schemasnpm run build:compliancenpm run test:schemasnpm run test:json-schemanpm run test:storyboard-sample-request-schemanpm run test:storyboard-response-schemanpm run test:docs-navnpm run check:owned-linksnpm run lint:schema-linksnpm run test:composednpm run test:storyboard-validations-pathsnpm run test:storyboard-context-output-pathsnpm run test:platform-agnosticnpm run test:error-codesnpm run test:sign-protocol-tarballnpm run test:oneof-discriminatorsnpm run test:schema-utf8npm run test:storyboard-check-enumnpm run test:storyboard-doc-paritynpm run test:substitution-vector-namesnpx vitest run server/tests/unit/comply-test-controller.test.ts --pool=threadsnpx vitest run server/tests/unit/training-agent.test.ts server/src/training-agent/tenants/tenant-smoke.test.ts --pool=threadstest:unit,test:test-dynamic-imports,test:callapi-state-change,typecheck