API visibility evidence
Termivar separates API observation from API interpretation. The evidence layer compares two views that the host has already authorized and paired; the decision layer turns the resulting immutable observation into reviewable hypotheses.
The explicitly enabled OpenAPI review follows the same separation. Its native executor commits bounded HTTP media and replayed document-catalog evidence to the parent assessment's shared knowledge base. The existing transport-neutral API reasoning rules may consume that committed evidence, but the reasoner never selects a document, dispatches a request, materializes a parameter, or executes an operation described by the contract.
authorized baseline JSON ----+
+--> ApiVisibilityComparator
authorized candidate JSON ---+ |
v
ApiVisibilityComparison
|
v
ApiVisibilityObservation
|
v
evidence + resource relation commit
|
v
StandardApiReasoning rules
|
v
resource-scoped review projection
The comparator and ingestion path shown above performs no network I/O, chooses no attack, carries no credential, and does not classify a vulnerability. The standard runtime also exposes a narrower broker-backed collector described below; it feeds this same comparison and ingestion boundary.
Bounded comparator
ApiVisibilityComparator borrows a serde_json::Value only while producing
two SHA-256 signatures. ApiVisibilityView retains the signatures, exact HTTP
status, declared API surface, selected limits, and non-secret opaque context and
resource handles. It never retains the JSON value or a canonical byte buffer.
The value has already been read and parsed before this API is called, so these
limits do not bound response-body buffering or JSON parser allocation. Hosts
must enforce a response-byte ceiling before parsing; the standard runtime's
max_response_bytes is that outer boundary when this comparator is integrated
there.
Canonicalization has explicit semantics:
- object keys are ordered by their UTF-8 bytes, so map insertion order is not evidence;
- array order and duplicate array elements remain meaningful;
Fieldscompares key and schema structure without scalar values;Resourcescompares the complete canonical JSON structure and values;Statuscompares the exact validated HTTP status;- views captured under different policies, resources, or API surfaces cannot be compared;
- both contexts must be distinct bounded opaque handles.
The default and compiled hard ceilings are:
| Dimension | Default | Hard ceiling |
|---|---|---|
| JSON depth | 64 | 128 |
| JSON nodes | 100,000 | 1,000,000 |
| Object fields | 50,000 | 250,000 |
| Bytes per canonical stream | 8 MiB | 64 MiB |
Every configurable value must be positive and no larger than its hard ceiling.
Persisted ApiVisibilityLimits reject unknown fields and are revalidated on
load. The serialized policy is versioned with the Preview comparator contract;
adding a required field is intentionally treated as incompatible until a
versioned migration exists. Limit exhaustion returns an error before a
comparison is emitted.
JSON numbers are encoded through the pinned serde_json implementation. Their
canonical text and the resulting fingerprints are deterministic for this
comparator/toolchain contract, but are not promised as permanent cross-version
wire hashes. Hosts that persist signatures for replay must pin dependencies and
record the Termivar comparator version.
Profiled comparator v3
The additive profiled API leaves ApiVisibilityLimits, ApiVisibilityView,
ApiVisibilityComparison, capture_view, and compare unchanged. This is
deliberate: the legacy comparison keeps its exact nine-field wire shape and its
existing evidence identity.
capture_profiled_view and compare_profiled add an explicit
ApiComparisonProfile and return ProfiledApiVisibilityComparison. The
profiled envelope persists:
- the comparator and canonicalization versions;
- a content-derived projection-policy ID;
- the validated resource limits;
- the nested legacy comparison;
- a globally bounded, raw-value-free
RedactedVisibilityDiff.
Comparator v3 makes explanations dimension-aware: a status comparison never attaches unrelated body paths, while a real difference without a representable path summary remains explicit rather than being mistaken for equivalence. Persisted v2 profiles and envelopes are deliberately rejected rather than silently reinterpreted under v3 semantics. Hosts that must replay historical v2 records need the matching pre-rebrand binary or an explicit offline migration. See ADR 0011.
Profiles support selected subtrees, ignored subtrees, and explicitly unordered
arrays. Paths use RFC 6901 escaping. A segment equal to * is Termivar's bounded
wildcard extension, primarily for structural array paths such as
/data/edges/*/node/id. Empty selection means the complete document. Ignore
rules take precedence, and construction rejects a selected path already hidden
by an ignored ancestor. Input order and duplicates do not change the policy ID.
use serde_json::json;
use termivar_core::{
ApiSurfaceKind, ApiVisibilityDimension, ApiVisibilityPairKind,
};
use termivar_scanner::{
ApiComparisonProfile, ApiVisibilityComparator, JsonPathPattern,
};
let profile = ApiComparisonProfile::new(
vec![JsonPathPattern::new("/data")?],
vec![JsonPathPattern::new("/data/request_id")?],
vec![JsonPathPattern::new("/data/items")?],
64,
)?;
let comparator = ApiVisibilityComparator::default();
let baseline = comparator.capture_profiled_view(
&profile,
"anonymous-view",
"resource:account-42",
ApiSurfaceKind::JsonHttp,
200,
&json!({"data":{"request_id":"a","items":[{"id":1}]}}),
)?;
let candidate = comparator.capture_profiled_view(
&profile,
"member-view",
"resource:account-42",
ApiSurfaceKind::JsonHttp,
200,
&json!({"data":{"request_id":"b","items":[{"id":1},{"id":2}]}}),
)?;
let report = comparator.compare_profiled(
&profile,
"comparison-18",
ApiVisibilityPairKind::AuthorizationContext,
ApiVisibilityDimension::Fields,
&baseline,
&candidate,
1_800_000_000_000,
)?;
assert_eq!(report.projection_policy_id(), profile.projection_policy_id());
assert!(!report.diff().added_path_hashes().is_empty());
# Ok::<(), Box<dyn std::error::Error>>(())
Every captured snapshot is first checked against the complete legacy resource
envelope. Selected or ignored paths therefore cannot conceal excessive input.
The profiled pass builds domain-separated tree hashes and a compact index of
path digests, type masks, and scalar-value digests. It retains neither clear
observed paths nor scalar values. Added, removed, changed-type, and
changed-value paths share one deterministic max_diff_paths quota;
omitted_diff_count records the exact remainder.
Path digests are pseudonymous, not confidential. Common field names can be
guessed by dictionary attack. A host may hash a reviewed allowlist with
PathDigest::for_pattern to resolve selected explanations, but must not treat
the digest as encryption or log serialized profiles containing sensitive
selector names.
Comparison checks version, canonicalization, projection policy, limits, contexts, resource scope, and surface before emitting a result. Its nested comparison ID is also bound to the version and policy metadata. Deserialization rejects an envelope whose nested identity does not match that metadata, so a persisted report cannot silently downgrade to a differently projected replay.
use serde_json::json;
use termivar_core::{
ApiSurfaceKind, ApiVisibilityDimension, ApiVisibilityPairKind,
ConfidenceScore, EntityId,
};
use termivar_scanner::{
ApiVisibilityComparator, ApiVisibilityReviewQuery, KnowledgeBase,
RuleEngine, StandardApiReasoning, api_visibility_reviews_for_resource,
ingest_api_visibility_observation,
};
let comparator = ApiVisibilityComparator::default();
let baseline = comparator.capture_view(
"anonymous-view",
"resource:account-42",
ApiSurfaceKind::JsonHttp,
200,
&json!({"id": 42}),
)?;
let candidate = comparator.capture_view(
"member-view",
"resource:account-42",
ApiSurfaceKind::JsonHttp,
200,
&json!({"id": 42, "email": "redacted@example.test"}),
)?;
let comparison = comparator.compare(
"comparison-17",
ApiVisibilityPairKind::AuthorizationContext,
ApiVisibilityDimension::Fields,
&baseline,
&candidate,
1_800_000_000_000,
)?;
let observation = comparison.to_observation(
"host.api-comparator",
ConfidenceScore::MAX,
)?;
let resource = EntityId::new("resource:account-42")?;
let knowledge = KnowledgeBase::new();
let mut rules = RuleEngine::new();
StandardApiReasoning::new()?.install(&knowledge, &mut rules)?;
let receipt = ingest_api_visibility_observation(
observation,
&resource,
&knowledge,
&rules,
)?;
assert_eq!(receipt.commit().resource_scope(), &resource);
let query = ApiVisibilityReviewQuery::new(32)?;
let page = api_visibility_reviews_for_resource(&knowledge, &resource, &query);
assert_eq!(page.reviews().len(), 1);
if let Some(cursor) = page.next_after_relation_id() {
let next_query = ApiVisibilityReviewQuery::new(32)?
.after_relation_id(cursor.clone())?;
let _next_page = api_visibility_reviews_for_resource(
&knowledge,
&resource,
&next_query,
);
}
# Ok::<(), Box<dyn std::error::Error>>(())
The example value is consumed transiently. Neither it nor the email string is stored in the view, comparison evidence, resource relation, receipt, or review projection.
Transport-free authorized runtime workflow
StandardWebDecisionRuntime can host the same ingestion and review boundary
when it is built with .enable_api_reasoning():
let mut runtime = StandardWebDecisionRuntime::builder(target)
.enable_api_reasoning()
.build()?;
let receipt = runtime.ingest_api_visibility(observation, &resource)?;
let page = runtime.api_visibility_reviews(
&resource,
&ApiVisibilityReviewQuery::new(32)?,
)?;
The facade accepts only the typed, host-created observation. It does not fetch
or pair responses and never receives raw JSON, credentials, or principal
identities. The host must authenticate the producer, authorize both views, and
assert their shared logical resource. Comparison subjects remain isolated, and
ingestion does not change runtime requests, response-byte accounting, planning,
experience, or decision-session state. A runtime without API reasoning enabled
returns RuntimeApiVisibilityError::ApiReasoningDisabled before any write.
Post-commit reasoning failures retain their observation receipt.
Native broker-backed authorization-context workflow
StandardWebDecisionRuntime::run_api_visibility_pair collects one explicit
control/candidate pair before entering the same Comparator V3 and observation
path. This is a host-triggered, single-use side path, not a planner-selected
capability or DecisionActionExecutor. It preserves the runner's rule that
normal executor evidence belongs to the outstanding endpoint subject; paired
evidence continues to use its isolated api-comparison:* subject.
The request is deliberately narrow:
- both probes are bodyless
GETrequests for the exact runtime target; - the target must use HTTPS, except for an exact HTTP loopback fixture;
- the pair is
AuthorizationContextover theJsonHttpsurface; - the host declares a bounded set of allowed credential and supporting anti-CSRF header names;
- at least one primary credential header differs, while every non-context header is identical;
- the comparison dimension is explicitly
Fields,Resources, orStatus.
Control and candidate receive separate redirect-disabled and implicit-retry-disabled connection pools. Both pools share the runtime's immutable HTTP policy and broker accounting authority. Each leg is charged as an active verification and consumes its own request lease. Delivered response chunks remain charged on timeout, cancellation, truncation, or later failure. No cookie or connection pool is reused from control to candidate.
Only two complete, non-truncated, JSON-compatible responses proceed. A 429,
server error, malformed JSON document, policy denial, transport failure,
cancellation, or budget stop cannot produce a paired observation. The response
bodies exist only while each bounded document is parsed and reduced to a
profiled view. A successful report retains the raw-value-free V3 comparison,
an observation/reasoning receipt for the atomic evidence/relation commit and
subsequent rule application, and the exact resource review projection.
ApiVisibilityDifferentialAudit is available on every post-start report and
on post-transport execution errors. It captures monotonic runtime usage and a
receipt for each completed leg; request-template and retained-body digests are
pseudonymous replay metadata, not secret-safe commitments. An incomplete pair
has no comparison. If cancellation or a wall limit arrives after comparison,
the report can retain the comparison; if it arrives after ingestion, the
committed observation and review can also remain. A later reasoning or review
projection error exposes the comparison and available commit receipt rather
than claiming rollback.
The only positive review state is the canonical weak, supported boundary
hypothesis with AwaitHumanReview. That state is never a broken-access-control
finding, verifier success, planner outcome, Experience update, or endpoint
decision-loop command. See
ADR 0013.
Four-view resource authorization workflow
The non-default resource authorization review reuses the same value-sensitive,
raw-value-free ApiVisibilityComparator view reduction, but it is not another
run_api_visibility_pair facade. One optional native action inside
WebAssessmentRuntime captures four views for one operator-selected
exact-origin JSON resource: primary candidate, peer candidate, primary replay,
and peer replay. It computes primary stability, peer stability, and two
cross-principal comparisons over Status, Fields, and Resources without an
additional request or a second JSON canonicalizer.
The strict security.authorization-review-policy/v1 profile requires bounded
exact JSON Pointer selections. Complete bodies exist only long enough to build
the four bounded view receipts. The retained contract excludes raw JSON,
canonical JSON bytes, scalar values, response bodies, credential material, raw
resource URLs, and clear selected paths. Four distinct response receipts, one
policy identity, one exact resource scope, complete path presence, and
correlation equality are mandatory.
StableCrossPrincipalEquivalence requires both roles to be independently
stable and both cross-principal rounds to be value-equivalent. Status or field
shape alone is insufficient. This state can project one
NeedsReview / KnowledgeOnly observation under the operator-declared
primary-only policy; it is not an IDOR, BOLA, broken-access-control, or data
exposure confirmation. Peer denial or non-equivalence creates no “secure”
finding. The transport-neutral API reasoner remains incapable of dispatching
these requests; the existing assessment executor owns them through the shared
broker.
REST read-only review reuses the same raw-value-free comparison foundation for
an exact replay, not for a cross-principal judgment. After same-run OpenAPI
candidate/replay stability, at most one anonymous, bodyless, exact-origin
zero-input GET is selected. Its candidate and replay must agree in Status,
Fields, and value-sensitive Resources. The comparison retains no scalar
values or response bodies and can produce only the informational
api.rest-readonly-surface-observed@1 observation. It grants no execution
authority to other documented operations or vulnerability families.
Commit and reasoning receipts
ingest_api_visibility_observation verifies the caller's expected resource
before writing. It then commits the comparison evidence and its sole
api.visibility.resource-scope relation atomically. Reasoning runs afterwards
on the pseudonymous comparison subject.
ApiObservationReceipt contains:
- the comparison subject and resource scope;
- the evidence and relation IDs;
- idempotent
KnowledgeWriteresults for both records; - rule applications and hypothesis write statuses in stable rule-ID order.
Each RuleApplication contains the candidate evaluated from that cycle's
snapshot. It is not a post-commit hypothesis clone: terminal-state preservation
can make stored state differ from the candidate. Consumers needing the
committed view must re-read the hypothesis from the knowledge base.
Comparison evidence is immutable within the supplied KnowledgeBase instance.
If rule evaluation fails after the pair is committed,
ApiObservationError::ReasoningAfterCommit carries the committed
ApiObservationCommitReceipt. A retry therefore has enough information to
distinguish a post-commit failure from a pre-commit rejection. Persistence
beyond the in-memory knowledge base remains a host responsibility. Materialized
hypotheses retain their evaluation timestamps, so full reasoning-receipt bytes
are not guaranteed to be identical across exact replays even though rule order
and idempotent write results are deterministic. Rule-produced hypotheses
themselves are batch-preflighted and written atomically, so a late identity
conflict cannot leave only the earlier conclusions from that reasoning pass.
The rule engine also compares subject and ontology revisions at commit time and
re-evaluates a stale snapshot within a fixed retry limit. Continuous concurrent
writes fail explicitly instead of allowing an old belief trail to overwrite a
newer one.
Resource review projection
api_visibility_reviews_for_resource preserves the original trusted-process
continuation API and returns a deterministic relation-ID-ordered page. Its
ApiVisibilityReviewQuery carries only a relation ID and therefore cannot
enforce resource binding. New integrations should use
api_visibility_reviews_for_resource_v2, pass its optional typed
ApiVisibilityReviewCursor, and derive the next token with
ApiVisibilityReviewPage::next_cursor. The v2 entry point validates the
cursor's resource digest before scanning. Both APIs use the same validated
scan limits: the default is 128 incoming relations and the compiled hard
ceiling is 1,024. Malformed, unrelated, or forged-looking relations are omitted
but still consume the scan budget. This keeps both cloning and inspection
bounded even when a resource has many rejected edges.
The knowledge store also rejects oversized relation records before insertion:
| Relation field | Hard ceiling |
|---|---|
| Relation ID | 512 bytes |
| Source or destination entity ID | 2,048 bytes each |
| Custom relation kind | 256 bytes |
| Evidence provenance | 32 IDs |
| Each provenance ID | 512 bytes |
The page clones at most its validated count of these bounded records. It checks
for a following relation against the borrowed index without cloning that
look-ahead record. Before cloning referenced records, the projection inspects
them while borrowed and rejects a producer component above 256 bytes or a
boundary rationale above 1,024 bytes. The ingestion boundary applies the
producer-component limit before its atomic write; the read-side check also
covers trusted code that writes directly to KnowledgeBase. Cursor IDs and
content fingerprints are redacted from the new API types' Debug output. The
v2 cursor contains a domain-separated SHA-256 resource digest plus the last
relation ID as lowercase hexadecimal bytes. The resource digest is
pseudonymous rather than confidential: dictionary attacks remain possible for
low-entropy identifiers. The cursor is deterministic and neither signed nor
authenticated. Hosts should avoid logging its serialized form, and an external
transport may wrap it in a signature or MAC before returning it to an
untrusted client.
The v2 cursor is bound to its resource but is not a frozen database snapshot. Concurrent inserts sort by their stable relation IDs; a relation inserted at or before an already consumed cursor may not appear in later pages. Hosts needing a point-in-time export must provide an external snapshot or quiesce writes.
Each review contains the comparison evidence and only a canonical-shaped visibility-boundary hypothesis. The expected standard hypothesis ID, predicate, pair, result, value, weak strength, supported state, and sole evidence binding must all agree; equivalent comparisons remain visible with an empty boundary list. These checks do not attest which rule installation produced the hypothesis. Surface and response-format hypotheses are intentionally excluded.
ApiVisibilityReview::disposition() keeps handling semantics explicit without
changing the serialized review record:
NoDifferenceObservedmeans the canonical evidence was equivalent;UnresolvedDifferencemeans a difference was stored but no exact canonical boundary hypothesis was available;AwaitHumanReviewmeans the exact weak/supported boundary hypothesis is present and bound to that comparison evidence.
This is a review-read-model state, not a target-scoped decision-loop command and
not a vulnerability verdict. Golden fixtures under
crates/termivar-scanner/tests/fixtures/api_authorization/ lock UI/API,
anonymous/authenticated, owner/unrelated-user, and read/write-capability
behavior through the full transport-neutral comparison and reasoning path.
Their expected projection-policy, comparison-subject, path, and serialized
envelope digests are literal fixture data rather than values regenerated by
the comparator under test. The three authorization-context fixtures also run
through the broker-backed native pair facade and must remain review-only with
no Experience or terminal hypothesis transition.
The projection validates canonical IDs, provenance linkage, evidence kind, method, reliability, predicate, dimension, and belief evidence before including a record. These are structural hygiene checks, not producer authentication.
Trust and privacy boundary
The comparator hashes raw values but does not encrypt them. Its signatures and the core comparison digest are pseudonymous deterministic fingerprints, not HMACs, signatures, attestations, or proof that a comparison occurred. An actor with a low-entropy candidate value may be able to reproduce a digest.
Hosts must authenticate evidence producers, authorize both views, and use non-secret opaque handles. The decision runner's one-case/one-subject evidence rule remains unchanged; paired comparison ingestion is deliberately outside that execution path. See ADR 0006.