Skip to content

storage/agent: export the candidate-issue shape type-only so bridge drift fails at compile time (land with the export gate) #2249

Description

@Jurij89

Follow-up from the review of #2243. This must land together with the storage export gate, not before it — see the sequencing section, which is the reason it was not taken in #2243.

The finding

packages/agent/src/system-records/agent-profile-materializer-bridge-v1-internal.ts declares AgentProfileMaterializerCandidateIssueV1, a structural mirror of storage's SystemRecordVerifiedCandidateIssueV1, and passes it through unknown to runtime.issuer.issueCandidate. The two shapes are kept in agreement by hand.

The consequence is real and this stack has a worked example of it: if storage adds a required field to the candidate issue shape, the agent-side mirror keeps compiling. Detection is runtime-only, via exactRecord's key throw, and only at the moment the two are actually wired — which is D7. conflictForkBaseHeadDigest (added by S2 in this same stack) is exactly such a field.

Ruling: valid, and the remedy is a narrow type-only export

Export the candidate-issue shape from storage — SystemRecordVerifiedCandidateIssueV1 plus the three types its union references (SystemRecordActiveReplacementIssueV1, SystemRecordTombstoneReplacementIssueV1, SystemRecordQuarantineReplacementIssueV1), none of which are currently in the barrel. Not the issuer, not the registry, not a factory. Then have the bridge import it and pin its local mirror against it, so drift fails the agent build at the construction site.

Two suggested alternatives were considered and declined: sharing the issuer type wholesale (breaches the capability boundary) and a storage-owned adapter accepting receiver candidates (inverts the dependency so storage imports agent types — strictly worse than the current direction).

Why this does not breach the isolation contract

D5a's isolation protects capability — who may mint, issue, or construct a proof — not shape. A type-only export grants no capability: it cannot construct anything, cannot reach the private registry, and erases at runtime.

That is not an assertion; it is what the pinning test actually says. packages/storage/test/system-record-verified-replacement-v1.test.ts:886, 'is not exported from the storage package barrel', asserts exactly two things:

expect('createSystemRecordVerifiedReplacementRegistryV1' in storage).toBe(false);
expect('resolveOwnedSystemRecordRuntimeV1' in storage).toBe(false);

Both are runtime factories. Nothing about types, and a type-only export cannot make an in check true. There is also existing precedent with sign-off: the bridge already does import type { SystemRecordApplyOutcomeV1, SystemRecordLaneSessionV1 } from '@origintrail-official/dkg-storage', and SystemRecordLaneSessionV1 is a type-only export of a privileged surface — the bridge calls session.applyVerified(proof) through it.

Preserve this in the code: after the change the issuer, the registry, the proof factory and the bridge factory all stay private, and nothing outside storage gains the ability to mint or transfer a proof. A reader six months out should not have to re-derive why exporting this one type was safe when the surrounding privacy is deliberate.

Sequencing — the reason this is filed rather than taken

An unproven enforcement is worth less than the guard it replaces. This converts a runtime refusal — the exactRecord throw, which is fail-closed and cannot be silently deleted because it is load-bearing code — into a compile-time check. A compile-time check that ships without an export gate can be silently dropped: any later barrel cleanup removes the export, compile-time detection quietly vanishes, and everyone keeps believing it is there.

At #2243's base, storage has no package-export pin at all. The gate (scripts/pack-gate/barrel-value-exports.json, probe-fixture.mjs, type-fixture.ts, verify-pack-exports.mjs, wired as test:package-exports) is being built by the storage exports-map work on chore/2165-storage-exports-map (issue #2165, now closed; the branch is unmerged, 20 ahead / 18 behind the integration base). Landing this export before that manifest exists means it arrives ungoverned, and that work would have to retro-adjudicate a public surface it did not add.

It also races directly: that branch changes exactly the two files this needs to touch — packages/storage/package.json (+14/-0) and packages/storage/src/index.ts (+13/-16, a deliberate narrowing of the barrel).

Onto the exports-map work it lands with its gate; onto #2243 it would land as a claim.

One framing worth keeping: this is an upgrade to detection latency, not a repair of a hole. Nothing is unguarded today — exactRecord refuses at runtime. That is why it does not earn a place in a converging stack, and why it should not be deferred indefinitely either: the hazard activates when D7 wires the bridge, so it must land before that wiring.

Acceptance

  • The four types are exported type-only from the storage barrel; no new runtime value appears.
  • The bridge imports the storage type and pins its local mirror against it, so a required field added in storage fails the agent build at the construction site.
  • The barrel-privacy test above still passes unchanged.
  • The export appears in the export gate's manifest, so removing it later fails the gate rather than silently disabling the check.

Related: #2052 (umbrella), #2243, #2165, #2244, #2245, #2246, #2247.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions