# V1a-C evidence: neutral Runtime record-access contract

> **Decision status:** contract implementation and release evidence for
> NexusCRM#3555 V1a-C. This phase defines one neutral semantic and wire
> capability. It does not add a Runtime adapter, switch the CRM route, or create
> a fallback.
>
> **Current-tree evidence revisions:** NexusCRM initial audit
> `964150890e86fccbea8268621bb76ea5551fb90f`, final revalidation
> `4364749f93847e9fe63d8ef67de7bce6301da5d2`; nexuscrm-app-packs
> `03435cd941f25e983c90dddd836dcbd1ae1fccea`; nexuscrm-contract base
> `77290832e809df5cafa6696e48dfc54b83c564f7`; nexuscrm-api-host `v0.4.0`
> commit `5930bcbb4bb46b709a69e1ce3a55372392834e31`.

## Decision

The neutral L0 operation is one coarse batch capability:

```text
runtime.records.evaluate-access.v1
EvaluateRecordAccess(optional object, 0..200 submitted record IDs)
  -> unique readable results in canonical record-ID order
  -> Read / Update / Delete / Locked
  -> one receipt set for the complete batch
```

Runtime owns optional object-type resolution, object CRUD, repository reads,
RLS/sharing, approval-lock and metadata LockRule evaluation, and generic delete
blocking inside this operation. L1 owns the Salesforce-compatible HTTP grammar
and projection. Org, user permission maps, `SecurityContext`, tokens and
`AppInstallationID` are absent from the business request; P6's verified
delegation and `TrustedCall` remain the only production authority channel.

This is one new use case on the existing coarse `RecordsService`; it is not a
remote copy of the three-method in-process `RecordAccessReader`. The legacy
interface contains `RetrieveWithAccess`, `BatchRecordAccess`, and
`ObjectTypesForIDs`
([`contract.go:85-100`](https://github.com/pingcap-inc/NexusCRM/blob/4364749f93847e9fe63d8ef67de7bce6301da5d2/shared/pkg/contract/contract.go#L85-L100)).

## Live production consumer and call path

The active CRM dialect registers `POST /ui-api/record-access`
([`partition.go:834-839`](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/03435cd941f25e983c90dddd836dcbd1ae1fccea/dialects/dialect-sf-crm/partition.go#L834-L839)).
The L1 handler parses `{objectApiName, recordIds}`, returns the CRM envelope,
accepts an empty list, and enforces the 200-entry bound
([`ui_api.go:475-498`](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/03435cd941f25e983c90dddd836dcbd1ae1fccea/dialects/dialect-sf-crm/ui_api.go#L475-L498)).

The consumer is not architectural test demand. The production CRM frontend
posts one object and record ID through `dataAPI.getRecordAccess`
([`crud.ts:102-119`](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/03435cd941f25e983c90dddd836dcbd1ae1fccea/salesforce-crm-standard/frontend/src/platform/infrastructure/api/data/crud.ts#L102-L119)).
Create-from-source authorization invokes it
([`ObjectView.tsx:696-719`](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/03435cd941f25e983c90dddd836dcbd1ae1fccea/salesforce-crm-standard/frontend/src/platform/pages/ObjectView.tsx#L696-L719)),
and record detail invokes it on the fallback path and converts missing/error
access to locked, non-mutable UI state
([`MetadataRecordDetail.tsx:390-418`](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/03435cd941f25e983c90dddd836dcbd1ae1fccea/salesforce-crm-standard/frontend/src/platform/features/record/components/MetadataRecordDetail.tsx#L390-L418),
[`MetadataRecordDetail.tsx:427-455`](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/03435cd941f25e983c90dddd836dcbd1ae1fccea/salesforce-crm-standard/frontend/src/platform/features/record/components/MetadataRecordDetail.tsx#L427-L455)).

NexusCRM production composition pins app-packs backend/frontend source at
`3c699bf9ffc787f23dd0364112e779344d615830`
([`source.go:3-20`](https://github.com/pingcap-inc/NexusCRM/blob/4364749f93847e9fe63d8ef67de7bce6301da5d2/backend/distributions/appsource/source.go#L3-L20)).
The route, handler and three frontend consumer files above are byte-identical
between that production source revision and inspected app-packs main
`03435cd941f25e983c90dddd836dcbd1ae1fccea`; the evidence is therefore not based
only on an unconsumed L1 head.

The final NexusCRM-main revalidation includes V1a-M. Within this serving call
path, that change only moved the generic-delete capability literal to its
shared constant owner
([`record_access.go:3-6`](https://github.com/pingcap-inc/NexusCRM/blob/4364749f93847e9fe63d8ef67de7bce6301da5d2/shared/pkg/constants/record_access.go#L3-L6));
it did not change the active route, adapter, repository, RLS, lock, or access
evaluation behavior. All NexusCRM call-path links below therefore target the
final revalidation SHA.

The current explicit-object call path is:

```text
CRM HTTP handler
-> L1 object Read gate
-> ContractService.BatchRecordAccess
-> MetadataService.GetSchema
-> PersistenceService.RetrieveManyRaw
   -> object Read
   -> RecordRepository.FindMany
   -> RLS/sharing filter
   -> permission-grant projection
-> PermissionService.BatchCheckRecordAccess
   -> object and row Read/Update/Delete
   -> approval lock
   -> metadata LockRule
   -> generic delete blocker
-> neutral four-boolean map
-> CRM response projection
```

The Records adapter performs the schema/read/access composition
([`contract_adapter.go:124-145`](https://github.com/pingcap-inc/NexusCRM/blob/4364749f93847e9fe63d8ef67de7bce6301da5d2/nexuscrm-records/contract_adapter.go#L124-L145)).
`RetrieveManyRaw` checks object Read, performs one batch repository read, omits
missing/RLS-denied rows, and deliberately does not apply FLS to its internal
policy inputs
([`persistence_bulk_ops.go:855-916`](https://github.com/pingcap-inc/NexusCRM/blob/4364749f93847e9fe63d8ef67de7bce6301da5d2/nexuscrm-records/persistence_bulk_ops.go#L855-L916)).
The final pass evaluates CRUD, row access and one approval-lock snapshot per
remaining row
([`record_access.go:412-465`](https://github.com/pingcap-inc/NexusCRM/blob/4364749f93847e9fe63d8ef67de7bce6301da5d2/nexuscrm-permissions/record_access.go#L412-L465)).
Approval lookup errors fail closed as locked
([`approval_lock.go:12-52`](https://github.com/pingcap-inc/NexusCRM/blob/4364749f93847e9fe63d8ef67de7bce6301da5d2/nexuscrm-permissions/approval_lock.go#L12-L52));
the UI LockRule signal and the authoritative write gate intentionally have
different malformed-formula behavior
([`locked_condition.go:14-103`](https://github.com/pingcap-inc/NexusCRM/blob/4364749f93847e9fe63d8ef67de7bce6301da5d2/nexuscrm-permissions/locked_condition.go#L14-L103)).
Generic delete suppression is metadata-declared and runs before super-user CRUD
bypass
([`delete_lifecycle_access.go:13-25`](https://github.com/pingcap-inc/NexusCRM/blob/4364749f93847e9fe63d8ef67de7bce6301da5d2/nexuscrm-permissions/delete_lifecycle_access.go#L13-L25),
[`service.go:493-501`](https://github.com/pingcap-inc/NexusCRM/blob/4364749f93847e9fe63d8ef67de7bce6301da5d2/nexuscrm-permissions/service.go#L493-L501)).

When `objectApiName` is absent, L1 currently trims and deduplicates IDs, resolves
types, groups by object, calls the batch seam once per type, omits forbidden
groups, and aborts on other group failures
([`ui_api.go:1189-1239`](https://github.com/pingcap-inc/nexuscrm-app-packs/blob/03435cd941f25e983c90dddd836dcbd1ae1fccea/dialects/dialect-sf-crm/ui_api.go#L1189-L1239)).
Remoting those methods would create resolver plus per-type RPC fan-out. The new
operation absorbs that work so each accepted non-empty L1 request needs exactly
one Runtime RPC. The existing empty no-op remains a local zero-RPC success.

## Observed semantics and contract projection

| Condition | Current observable behavior | Neutral v1 fact |
|---|---|---|
| malformed JSON | HTTP 400 CRM `INVALID_REQUEST`; no Records call | L1 rejects; zero Runtime RPC |
| empty submitted list | HTTP 200 empty map; current handler makes no Records call | semantic request is valid and yields empty results with receipts when invoked; production route remains a local zero-RPC success |
| 1–200 submitted entries | accepted; duplicates count before normalization | accepted; submitted count is the bound |
| 201 entries | HTTP 400 CRM `LIMIT_EXCEEDED`; no Records call | `RESOURCE_EXHAUSTED/RECORD_COUNT`, maximum 200 |
| explicit object Read denied | HTTP 403 | top-level `FORBIDDEN` |
| omitted object, forbidden resolved group | group omitted; allowed groups remain | indistinguishable result omission |
| missing record or RLS/sharing Read denial | record key omitted | identical result omission; top-level `MISSING` is invalid |
| visible submitted record | four booleans | one result with `Read=true`; an unrequested ID is an internal protocol failure; `Locked=true` requires Update/Delete false |
| duplicate IDs | output map collapses duplicates | one canonical result; input duplicates still count toward 200 |
| operational failure/cancellation/deadline | no partial HTTP success | no response plus one stable top-level failure |
| FLS | not a public projection step; raw fields remain Runtime-internal policy inputs | no field values exist in request or response |

Results are unique and sorted by ascending bytewise UTF-8 record identifier.
That creates a deterministic protobuf list without making L0 own the CRM map
envelope. The full batch has one `CatalogRevision`, `SchemaGeneration`,
`EngineGeneration`, `SecurityEpoch`, protocol version, and capability version.
Missing, denied and unresolved records cannot carry per-record explanation.

Only `STALE_STATE` after an authoritative state refresh and `UNAVAILABLE` with
backoff are retry-eligible. Retry count and total route budget remain L1-owned.
Cancellation and deadline are final. A native deadline is required; current
record-access evidence approves no numeric attempt maximum, so selected-root's
2-second capability limit is not copied.

## Limits and wire envelope

| Value | Classification | Decision/evidence |
|---|---|---|
| 200 submitted IDs | existing route behavior and v1 business bound | handler checks before normalization |
| 64-byte object identifier | existing approved physical-name owner, reused by v1 | ASCII identifier grammar |
| 255-byte record identifier | existing outer Runtime identifier envelope, reused by v1 | concrete object schemas may be narrower |
| 25 MiB HTTP body | existing generic host buffering safety cap only | binder default is explicit and lowerable; not a record-access business limit ([`binder.go:502-520`](https://github.com/pingcap-inc/nexuscrm-api-host/blob/5930bcbb4bb46b709a69e1ce3a55372392834e31/dialecttransport/binder.go#L502-L520)) |
| request deadline | existing requirement for Runtime operations | no new numeric cap in V1a-C |
| overload admission | existing approved fail-fast Runtime invariant | excess work is `RESOURCE_EXHAUSTED`; replica concurrency remains deployment configuration |
| semantic request/response bytes | no independent v1 limit | count and identifier bounds already produce a smaller finite wire shape |
| gRPC receive/send | existing wire envelope: 128 KiB / 2 MiB | compression remains disabled |

The generated maximum request is 51,666 bytes; the 201-entry one-over probe is
51,924 bytes. The maximum complete authorized response is 53,490 bytes; the
201-result one-over probe is 53,757 bytes. Relative to the existing transport
caps this leaves 79,406 request bytes and 2,043,662 response bytes of protobuf
payload headroom. These are deterministic codec measurements, not production
traffic or SLO claims. No semantic byte limit is invented merely to equal the
transport configuration.

## Stable failure matrix

| Category | gRPC code | Batch policy | Automatic retry |
|---|---|---|---|
| `INVALID_REQUEST` | `INVALID_ARGUMENT` | no response | never |
| `UNAUTHENTICATED` | `UNAUTHENTICATED` | no response | never |
| `FORBIDDEN` | `PERMISSION_DENIED` | no response | never |
| `CANCELED` | `CANCELED` | no response | never |
| `DEADLINE_EXCEEDED` | `DEADLINE_EXCEEDED` | no response | never |
| `RESOURCE_EXHAUSTED` | `RESOURCE_EXHAUSTED` | no response | never |
| `INCOMPATIBLE` | `FAILED_PRECONDITION` | no response | never |
| `STALE_STATE` | `FAILED_PRECONDITION` | no response | after authoritative refresh only |
| `UNAVAILABLE` | `UNAVAILABLE` | no response | backoff inside the original L1 deadline |
| `INTERNAL_FAILURE` | `INTERNAL` | no response | never |

`FAILED_PRECONDITION` requires typed capability detail because it is ambiguous.
The reviewed undetailed transport outcomes are limited to authentication,
permission, cancellation, deadline, envelope exhaustion, and unavailability.
Arbitrary status text is never projected. Typed invalid-component and limit
details use a record-access-specific allowlist with exact owned maxima;
selected-root fields/limits and compatibility details naming another
capability fail closed as `INTERNAL_FAILURE`. A top-level `MISSING` is likewise
converted to `INTERNAL_FAILURE`, preserving the batch omission rule.

## Compatibility and conformance ownership

The contract release carries immutable app-neutral vectors for:

- empty semantic no-op plus the composition rule of one RPC for non-empty and
  zero for empty/rejected input;
- object CRUD and row sharing differences;
- owner/share/RLS/missing omission;
- approval lock, metadata LockRule, and generic delete blocking;
- mixed resolved object types, forbidden groups, unknown IDs, duplicates and
  canonical ordering; and
- cancellation, deadline, no partial response, invalid envelopes and every
  one-over boundary.

The semantic root remains standard-library-only. Protobuf, gRPC, generation,
codec mapping and transport tests remain in the separately released `wire-go`
module. The wire adds exactly one method to the existing service. Buf lint,
both immutable breaking baselines, deterministic regeneration, generated
client/server execution, capability/interceptor mismatch, compression denial,
oversized messages, clean external consumption, race and dependency-boundary
checks are release gates.

The sealed app-packs issue-tracker point-read fixture is not modified in this
phase: it proves a different selected-root capability, and pretending it
already exercised access evaluation would manufacture evidence. The immutable
record-access vectors are ready for a capability-specific test-only consumer
when the real Runtime adapter is added. That later fixture extension must stay
CI-only and must not create a route, App, AppInstallation, image or deployment.

RPC count is a composition fact, not a semantic-message fact. The released
operation is sufficient to evaluate any accepted `0..200` request in one call,
and its generated transport tests prove the empty request is valid. The CRM
adapter/conformance phase must separately prove exactly one call for accepted
non-empty input, zero for empty/rejected input, and no per-ID or per-type L1
fan-out. This preserves the current empty success during Runtime outages rather
than manufacturing a service dependency for a request that reads no fact.

## Releases and validation

Reviewed release boundaries:

| Boundary | PR | Merge SHA | Version |
|---|---|---|---|
| semantic root | `nexuscrm-contract#24` | `57b368d7da978a4c1ab28ea8a2a677909fdc4878` | `v0.1.9` |
| generated wire | `nexuscrm-contract#25` | `dcdb3300598aa895c44c92dc26314f07f1db4130` | `wire-go/v0.1.2` |

The semantic reviewed head was
`391475690e385b5da3102b6bf6059f92fec5e97d`; exact-head Contract run
`29271834601` passed, and review run `29271832849` recorded standard and
adversarial APPROVE with no remaining issue. The wire reviewed head was
`b0437c237c7e2c2dc30f0a19f639b3e851b6829a`; exact-head Contract run
`29273720678` passed, and review run `29273718826` recorded standard and
adversarial APPROVE with zero blockers and zero suggestions.

Both Codex Review jobs subsequently reported failure only in their
post-verdict auto-merge mutation because GitHub returned transient PR state
`UNSTABLE`; their immutable aggregate comments say `ACCEPT`. The approved heads
were squash-merged without another code change. This workflow defect is not a
contract validation failure and is recorded rather than relabeled as a green
check.

Validation evidence must include:

```text
scripts/check.sh
GOWORK=off go test -race ./pkg/runtime/recordaccessv1 -count=1
GOWORK=off go test -race ./mapping ./compatibility -count=1   # wire-go
GOWORK=off go vet ./...                                      # both modules
clean external go get/test of both published versions
NexusCRM boundarygate and focused app-authority guard
git diff --check
```

All commands above passed at the applicable reviewed head. A fresh external Go
module with `GOWORK=off`, the private-module settings, and no `replace`
successfully downloaded `recordaccessv1@v0.1.9` and
`wire-go/mapping@v0.1.2`, then compiled and tested the semantic package, mapping
package, and generated Runtime client/server package together.

## Follow-up gate

V1a-C does not prove production equivalence. The next adapter/fixture phase must
execute the immutable vectors against the real
`ContractService -> PersistenceService` owner and compare the current CRM
route's canonical HTTP status/body. P7c/S1 must then bind the operation to one
`TrustedCall` and one immutable metadata view before any route switch. The
eventual cutover must fence one active owner and delete the legacy L1 fan-out
path; it may not add a fallback.

The current api-host policy is explicitly selected-root-only: its configuration
caps attempts against `recordsv1.MaxAttemptDuration`, and its method gate rejects
every full method except `ReadSelectedRoot`
([`policy.go:51-101`](https://github.com/pingcap-inc/nexuscrm-api-host/blob/5930bcbb4bb46b709a69e1ce3a55372392834e31/runtimegrpc/policy.go#L51-L101),
[`policy.go:143-174`](https://github.com/pingcap-inc/nexuscrm-api-host/blob/5930bcbb4bb46b709a69e1ce3a55372392834e31/runtimegrpc/policy.go#L143-L174)).
The adapter phase must make that host policy capability-aware and preserve one
ordered security/admission path. It must not silently treat selected-root's
numeric attempt cap as record-access evidence or create a second interceptor
chain.
