# Helix Data Access Management V1

> Status: implementation contract for [#809](https://github.com/pingcap-inc/Helix/issues/809).
> First executable task: [#812 DAX-001](https://github.com/pingcap-inc/Helix/issues/812).
> Current request-scope ADR: [DECISIONS.md](DECISIONS.md#2026-08-22--dax-申请使用统一资源选择器并允许跨数据集混合粒度).

This document is the source of truth for Helix data-access management V1.
Implementation issues may slice the work, but they must not redefine dataset
identity, principal ownership, entitlement semantics, approval routing, or
provider mutation ownership.

## Bottom Line

Helix V1 manages one narrow data-access path:

```text
catalog-fed or ops-bootstrap BigQuery dataset
  -> dataset resource OR inherited/delegated child table data_access_resource
  -> Feishu-backed data_access_request
  -> approved data_access_binding
  -> persisted compiled BigQuery effect
  -> data-access reconciler
  -> exact BigQuery dataset ACL OR table/view IAM
  -> audit and run history
```

Only the reconciler mutates BigQuery IAM. Request submission services, Feishu
payload builders, and Feishu callback handlers must not call BigQuery directly.

## V1 Scope

| Axis | V1 contract |
| --- | --- |
| Domain | `data_access` |
| Provider | `bigquery` |
| Resource type | `dataset` and/or `table` for new personal/Service Account requests; one request may mix scopes across different datasets but never select a dataset together with its child table |
| Principal type | `google_group`, `gcp_service_account`, or authenticated `google_user` |
| Entitlement | `read` |
| Provider effect | `serviceAccount:<email>` or `user:<work_email>` has `roles/bigquery.dataViewer` on one exact BigQuery dataset or table/view |
| Approval | one Feishu approval for one principal and entitlement; every item snapshots the owner of its selected dataset/table resource |
| Execution | semantic desired state is compiled, stored, and reconciled by Helix |
| Initial dataset source | catalog-federated resources; ops-only bootstrap is allowed until catalog cutover |

The canonical Feishu flow names are `data_access_group_grant`,
`data_access_service_account_grant`, and `data_access_personal_grant`. The
Group flow is legacy compatibility only: it finishes callbacks and revokes for
existing records, but has no new public submission path.

## Current Principal Modes

Personal and typed Service Account modes share the same
`data_access_resources` resource graph and canonical `read:v1` entitlement,
but not identity authority:

Current dataset/table Owner authority follows the Helix offboarding lifecycle.
When an offboarding process completes, it locks every non-deleted DAX resource
still owned by the leaver and assigns those current-owner facts to the existing
terminal handover person resolved by the offboarding domain. Active and
suspended resources participate; deleted rows remain historical. If such
resources exist but no active terminal successor can be resolved, completion
fails before the person is flipped to `left`. This does not rewrite request-item
Owner snapshots, approvals, bindings, compiled effects/runs, revocations, or
BigQuery IAM; only the DAX reconciler remains allowed to mutate Provider state.

- A Service Account request accepts a validated typed SA email. It is not a
  Helix catalog-admission lookup.
- A personal request has no person or email field. The server derives its
  `google_user` principal from the authenticated active employee's UUID and
  current work-email snapshot. It compiles only `user:<that work email>` to a
  BigQuery `userByEmail` ACL entry.

Both resource grains route dataset items to dataset owners and table items to
effective table owners. Personal requests go directly to that dynamic resource-owner
review; Service Account requests retain requester-line-manager then resource-owner
review. The UI exposes one picker: a dataset-row checkbox selects the whole
dataset, while opening its name selects individual tables lazily. One request may mix
whole datasets and tables from other datasets; within one dataset the parent and child
selection are mutually exclusive. Existing Google Group requests retain their historic approval/callback
semantics, bindings, effects, revokes, and read-only access history; no new
Group request UI or HTTP submission route exists. Submission and callbacks
persist only Helix semantic state; the DAX reconciler is the only BigQuery
writer. An active personal binding is moved to
`revoked_pending` when the authoritative employee becomes inactive, disappears,
or its work-email snapshot changes; the existing absent-effect reconciler then
removes only the old exact ACL entry. Personal effects additionally require the
default-closed integration setting `personal_apply_enabled=true`, on top of the
normal DAX apply mode, before the reconciler may write either direction.

## Feishu Approval Definition Artifact

Feishu approval definitions for data access are repo-stored operations
artifacts, not runtime-authored objects. The reviewed legacy sample for the
Group grant flow lives at
[`deploy/feishu/approval-definitions/data_access_group_grant`](../../deploy/feishu/approval-definitions/data_access_group_grant).

Feishu admins may manually create and publish the definition from that
directory, or operators may run the manual GitHub Action
`Deploy DAX Feishu Approval`, which uses the same artifact as input. That
Action is a deploy-time/manual operation, not Helix runtime: it calls Feishu's
approval-definition API, writes the resulting
`FEISHU_APPROVAL_DATA_ACCESS_GROUP_GRANT_CODE` into SSM, and can invoke
`deploy/scripts/upsert-dax-approval-flow.sh` on EC2 to run
`server/scripts/upsert_data_access_approval_flow.py` inside the backend
container. The script upserts `integration_approval_flows.external_code` and
the canonical `payload_schema` for flow key `data_access_group_grant` on the
primary `chat_approval/feishu` integration with
`accept_external_origin=false`.

Helix runtime stores only the approval-flow mapping, including
`payload_schema` widget ids, and creates approval instances against that
preexisting approval code. It must not create, patch, or delete Feishu approval
definitions during app boot or request handling.

The definition artifact deliberately uses custom widget ids (`dax_*`) so local
and production runs exercise the same canonical-field-to-Feishu-widget mapping
that a manually deployed template will use. Provider mutation remains owned by
the data-access BigQuery reconciler, never by request submission or Feishu
callback handling. Feishu v4 definition create/update payloads must send
approval names, descriptions, widget labels, and node names as `@i18n@...`
keys plus matching `i18n_resources`; the API rejects definition updates without
those resources.

Feishu form fields are approver-facing, human-readable prose: request summary,
access to grant, dataset list with catalog description when present, requester
note/business reason, and Helix detail link. The raw semantic intent, dataset
owner context, and compiled provider preview stay in Helix's
`approval_requests.source_payload` for audit, callback validation, and
recovery; they are not submitted as raw JSON form fields.

The verified Feishu v4 definition API shape is
`POST /open-apis/approval/v4/approvals`. Service Account and legacy Group
definitions retain a two-step process: first the requester/applicant's line
manager, then the resource owner. Their line-manager node is configured as an
automatic requester-manager/personnel-manager approver, so Helix does not send
it in the instance payload. The personal definition intentionally has no
manager node and contains only the dynamic resource-owner review:

- `requester_line_manager_review`: `node_type="AND"`, approver
  `{"type": "Supervisor", "level": "1"}` (Service Account and legacy Group only)
- `data_owner_review`: `node_type="OR"`, approver `{"type": "Free"}`,
  `approver_chosen_multi=true`

The owner-review node is dynamic: the published definition should name the
node/custom id `data_owner_review`, and the Helix approval-flow mapping should
include:

```json
{
  "approval_process": {
    "sequence": [
      {
        "key": "data_owner_review",
        "approver_source": "helix_resource_owner",
        "instance_payload_field": "node_approver_open_id_list"
      }
    ],
    "approver_nodes": {
      "data_owner_review": {
        "id": "data_owner_review",
        "approver_source": "helix_resource_owner",
        "instance_payload_field": "node_approver_open_id_list"
      }
    }
  }
}
```

When that node mapping is present, the shared DAX owner-approver builder resolves
only the selected resources' effective `owner_person_id` values to Feishu ids and
passes those to Feishu's create-instance API via `node_approver_open_id_list` /
`node_approver_user_id_list`. For Service Account and legacy Group definitions,
Feishu also resolves the requester line manager from the applicant id (`user_id` /
`open_id`) on the instance; personal requests do not ask Feishu to do so. If the
owner node mapping is absent, Helix assumes a disposable fixed-approver template.
If the owner node mapping is present but an owner cannot be resolved, submission
fails before any Feishu instance or durable request state is created.

## Non-Goals

V1 explicitly does not include:

- on-behalf personal access or a caller-supplied personal email/person id
- BigQuery project IAM
- `bigquery.jobUser`
- broad BigQuery dataset scanning
- dataset creation
- a visible manual dataset catalog UI
- Terraform or another runtime apply engine
- Feishu template authoring inside Helix
- declarative Google Group membership reconciliation
- request-to-join-group, which stays in the `email_group` flow

If an implementation needs one of these to make progress, it is outside V1 and
requires a new ADR before code lands.

## BigQuery Reconciler Credential Least Privilege

The BigQuery DAX reconciler credential is a dedicated
`kind='data_access'`, `provider='bigquery'` integration secret stored only as
that integration's `service_account_json`. Request rows, bindings, compiled
effects, and effect run history store semantic ids, locators, provider members,
provider roles, outcomes, etags, and errors; they must not store service-account
JSON, private keys, OAuth tokens, raw BigQuery policies, or raw provider
responses.

The service account uses the BigQuery OAuth scope:

```text
https://www.googleapis.com/auth/bigquery
```

The scope is necessary for the dataset-IAM write path; it is not the
authorization boundary. Authorization comes from the Terraform-managed BigQuery IAM
custom role, granted at the narrowest dataset scope from the repo seed. The
role is named in Helix docs as `HelixDaxDatasetIamReconciler` and contains
exactly:

```text
bigquery.datasets.get
bigquery.datasets.getIamPolicy
bigquery.datasets.update
bigquery.datasets.setIamPolicy
bigquery.tables.get
bigquery.tables.getIamPolicy
bigquery.tables.list
bigquery.tables.setIamPolicy
```

Google's BigQuery dataset ACL documentation maps dataset metadata/access reads
to `bigquery.datasets.get` plus `bigquery.datasets.getIamPolicy`, and ACL
updates to `bigquery.datasets.update` plus `bigquery.datasets.setIamPolicy`.
The request-page schema browser uses `bigquery.tables.list` to enumerate tables
inside one already-requestable dataset and `bigquery.tables.get` to read the
selected table's schema and field descriptions. The reconciler uses
`bigquery.tables.getIamPolicy` / `bigquery.tables.setIamPolicy` only for an
approved exact-table effect. It never reads table rows.

When that dedicated Provider integration is absent or its metadata read is
unavailable, the same paginated table-list endpoint may return the deployed dbt
lineage catalog's non-source relations as an explicitly read-only projection.
Those rows carry `metadata_source=lineage_catalog`, `selectable=false`, and
`schema_available=false`; clients must not use them for a request, schema claim,
or owner mutation. This fallback preserves lineage visibility without treating
the image-bundled catalog as proof that a BigQuery object currently exists.
See
[Changes to dataset-level access controls](https://docs.cloud.google.com/bigquery/docs/dataset-access-control)
and
[Control access to resources with IAM](https://docs.cloud.google.com/bigquery/docs/control-access-to-resources-iam).

Do not use `bigquery.readonly` for this reconciler: it cannot support the
future set path and is weaker evidence for ACL-specific permissions. Do not use
project IAM roles, BigQuery Admin/Data Owner broad roles,
`roles/bigquery.jobUser`, `bigquery.jobs.create`, dataset listing, table data
permissions, or the CMDB cloud-grant service account. Query execution remains
outside DAX V1; the consumer Google Group gets dataset `read` semantics, while
Helix's dedicated credential manages dataset access-control entries and reads
schema metadata for the requestable-catalog UI.

## Requestable Dataset Schema Browser

The employee request page may inspect schema metadata only after the backend has
resolved the resource id to an active, requestable, owner-resolved BigQuery
dataset. `GET /api/v1/data-access/requestable-resources/{resource_id}/tables`
lists tables/views within that exact locator and paginates the stable result;
`GET .../tables/{table_id}/schema` reads one table on demand. Nested RECORD
fields are flattened to full paths such as `owner.department`, while type,
mode, ordinal position, and nullable BigQuery field descriptions remain
separate response fields.

Schema reads are a read-only discovery surface, not an authorization side channel: a
suspended/non-requestable resource id returns 404, provider errors become a
generic 503, raw provider responses and credentials never enter the HTTP
response, and the frontend never calls GCP directly. No table data, sample
rows, statistics, query SQL, jobs, or project IAM are part of discovery. The
same drawer is also the table selector. It returns the effective table owner
and allows only that current table owner to transfer the table through
`PUT .../tables/{table_id}/owner`; the compatible MCP twin remains
`reassign_data_access_table_owner`. The dataset list similarly exposes a
current-owner-only `PUT .../requestable-resources/{resource_id}/owner` with MCP
twin `transfer_my_data_access_dataset_owner`.

### dbt Table Lineage

The adjacent Lineage tab answers two role-specific questions through the same
read path. `GET .../tables/{table_id}/lineage` first rechecks the same
active/requestable dataset boundary, then reads the versioned dbt dependency
catalog bundled in the Helix server image. A selected dbt-produced relation
returns `direction='upstream'` and the path from raw sources through staging and
intermediate models to that serving table. A selected manifest source returns
`direction='downstream'` and the real `source`/`ref` edges through the dbt models
that consume it. An untracked table returns `status='not_tracked'`; Helix does
not infer either direction from names or SQL text.

The build projection canonicalizes nodes by the exact physical
`project.dataset.table` locator before publication. If dbt declares one
physical relation under multiple internal aliases, every dependency is
remapped to one stable node, preferring dbt-produced `model`, `snapshot`, or
`seed` nodes over a `source` declaration. The deployed artifact must therefore
contain exactly one node per physical locator. This keeps table lookup
unambiguous and prevents a dbt-produced relation from being presented as an
external raw source merely because it is also declared through `source()`.

The catalog is generated by
[`build_data_access_lineage_catalog.py`](../../server/scripts/build_data_access_lineage_catalog.py)
from a reviewed dbt `manifest.json`, and its source revision is returned to
the UI. It is a display-only projection, not DAX authorization or provider
state: no BigQuery query job, table-data read, DB write, approval, IAM change,
or raw manifest read occurs on the request path. MCP
`get_data_access_table_lineage` mirrors the HTTP read boundary.

Only manifest nodes with `resource_type='source'` that are reachable upstream
from an active/requestable dbt result dataset already admitted by Helix are
treated as external lineage roots. Traversal stops at the source node. A
source-only dataset is not a new traversal origin, and an unused or unreachable
source declaration is not imported.
[`sync_data_access_lineage_roots.py`](../../server/scripts/sync_data_access_lineage_roots.py)
adds a missing reachable source dataset to the projected inventory as active owner
metadata, with owner `xiaopeng.zhu@pingcap.com`, `dbt_generated=false`, and
`requestable=true`; existing referenced source rows become requestable without
rewriting their owner. A stale row previously derived by lineage is omitted when
it is no longer reachable; manually governed rows are retained. It never adds
parentless `model`, `seed`, or `snapshot` datasets. A seed run projects those
rows into `data_access_resources`, and the
separate Terraform apply gives the DAX service account exact dataset-level
metadata/table-IAM scope only for active/requestable rows in the explicitly
allowed `gcp-digital-bi` and `gcp-digital-bi-dev` projects.

The projection no longer depends on an operator noticing a missing source and
committing it first. `seed_data_access_resources.py --lineage-catalog ...`
re-runs the same assembly rule against the version-bound lineage artifact before
each projection. Staging deployment treats that committed projection as a
release gate: it verifies a content-addressed inventory, uses the exact release
image's lineage catalog, and fails if owner resolution or the transaction
fails. A sanitized Staging cohort may omit an explicit Production owner; only
that deployment path passes `--unresolved-owner-fallback-email` to resolve the
row to the retained Xiaopeng Zhu identity. Production never passes this option
and preserves the inventory owner. The Production catalog workflow keeps an
explicit main-only write gate, uploads both artifacts, and derives roots
automatically. In addition to manual dry-run/commit, it runs an idempotent
committed reconciliation from the latest default-branch artifacts every day at
06:20 Asia/Shanghai. Neither path lists all BigQuery datasets or loads table
schemas; table discovery and schema reads remain on demand. The schedule also
does not apply Terraform or widen Provider IAM.

The requestable-resource and table-list HTTP contracts remain `/api/v1`, use
the existing auth and bounded pagination, and evolve additively. Catalog table
rows expose `lineage_direction` plus `downstream_reference_count`; the detail
response exposes `direction`. A healthy
Provider lists current source tables and enables selection. The provider-free
fallback includes tracked source relations but marks them `lineage_catalog`,
`selectable=false`, and `schema_available=false`; a static manifest therefore
cannot by itself authorize a request.

For a source dataset already admitted by at least one reachable edge, the table
list may also contain manifest source declarations with zero downstream edges.
Those rows are labeled as having no downstream reference rather than as used;
they do not independently widen dataset admission. The lineage response additively enriches source nodes from those projected
dataset rows with `dataset_owner_*`, `dataset_cataloged`, and
`dataset_requestable`. The lookup is one integration-scoped batch query, not
one query per node. Those fields are serialized only when the graph node itself
is a `source`; a model sharing the same dataset cannot inherit the source-owner
display fields. The drawer de-duplicates source tables by dataset and renders
a compact source-owner legend above the graph; intermediate model owners are
neither queried nor shown. If a graph starts at a parentless model, seed, or
snapshot, the first column is labeled as lineage starting nodes rather than raw
sources. The level columns are a stage overview only: they do not draw generic
arrows that could be mistaken for real edges. A bounded, scrollable relationship
list consumes every response edge and renders its exact upstream and downstream
physical locator, including branches and dependencies that skip visual levels.
Owner labels expose display name only; work email is used solely as a fallback
when no display name exists.

## Table Resource and Owner Inheritance

The dataset catalog remains the only source for which datasets are requestable.
Helix materializes a child `data_access_resources` row only when a live BigQuery
table/view is selected or its owner is changed. The row stores the stable
`project_id.dataset_id.table_id` locator and approval owner, not a copied schema.

New child rows use `owner_source='dataset_inherited'` and copy the current
dataset owner. Later effective dataset owner changes update only inherited
children. `owner_source='manual'` rows retain their independent owner. The
current dataset owner may transfer the dataset to another active employee;
inherited children follow atomically while manual children stay unchanged. The
current effective table owner may transfer only that table; the dataset owner
has no override after a manual table handoff. Dataset transfers retain catalog
or bootstrap provenance as `catalog_transferred` / `bootstrap_transferred`, so
later catalog refreshes update metadata without silently restoring an old
owner. Every new request references child table resource ids, and compiler v2 persists
`target_table_id` through effect/run history. Legacy dataset resource ids,
bindings, effects, callbacks, revokes, and read models remain supported.

Table IAM uses `tables.getIamPolicy` / `tables.setIamPolicy` with an etag
precondition and merges only the exact unconditional
`roles/bigquery.dataViewer` member. Conditional and unrelated bindings are
preserved. The BigQuery Python table client is called with its supported IAM
policy version 1 on both observe/read and set-before-write paths; dataset ACL
REST policy version 3 is a separate protocol. Present apply re-reads the current resource row and requires it to
remain active, requestable, and owner-resolved; integration/type/principal
gates continue to apply. There is no second Service Account dataset allowlist.
Absent/revoke effects deliberately ignore current requestability so a removed
resource cannot strand an existing provider grant.

## BigQuery Dataset ACL Patch Behavior

BigQuery exposes dataset access controls through the dataset resource's
`access` array. Helix's DAX driver reads that array with `datasetView=ACL`
and normalizes only unconditional dataset-reader Google Group entries into
semantic DAX bindings:

```text
BigQuery access role READER
  -> groupByEmail: <email>
  -> group:<email>
  -> roles/bigquery.dataViewer
```

The write path updates only the ACL surface (`UPDATE_ACL`) and must merge
through the current `access` array instead of replacing the dataset resource.
DAX may add or remove only exact compiled effects:

```text
role: roles/bigquery.dataViewer
member: group:<email>
resource: one explicit project_id.dataset_id locator
```

New DAX Google Group grants must be emitted in BigQuery's dataset ACL resource
shape as `READER` + `groupByEmail`. Conditional access entries are not exact
DAX grants: observe mode must not count them as satisfying an unconditional
compiled effect, and the patch planner must preserve them while adding the
separate unconditional entry when needed.

All unrelated access entries must be preserved byte-for-byte in the update
body, including conditional ACL grants, owners, writers, special groups,
authorized views/routines, user entries, domains, and any non-DAX IAM-style
entries. DAX must not infer, create, or delete project IAM,
`roles/bigquery.jobUser`, table access, query jobs, or Google Group membership
while patching dataset ACLs.

DAX-031 wires the planner to BigQuery through a guarded driver method that
calls the REST dataset `PATCH` path with `updateMode=UPDATE_ACL` and
`accessPolicyVersion=3`, after re-reading the same dataset locator with
`datasetView=ACL` and `accessPolicyVersion=3`. The driver compares the current
provider etag with the caller's expected etag and sends the expected etag as
the provider `If-Match` precondition on the patch request. Stale local etags
and provider 409/412 precondition failures return conflict results and must
not be treated as active effects. The driver write method itself remains
fail-closed unless apply mode is enabled. The dataset write boundary is the
Terraform-managed BigQuery service account IAM grant, not runtime integration
config.

DAX-032 makes the runtime switch a reconciler-owned rollout gate. The
integration `config_public` stores only `apply_mode_enabled`; dataset scope is
managed by `infra/gcp-dax`, which reads the active/requestable rows from
`deploy/data-access/datasets/*.json` and grants the DAX service account
dataset-level IAM only on those locators. The reconciler helper
`resolve_apply_reconcile_gate(...)` locks only the selected pending compiled
effects, classifies each effect as allowed/blocked by the runtime switch, audits
`data_access.reconcile.apply_gate`, and leaves effects/bindings/requests
unchanged. A disabled switch or invalid integration config stops mutation
eligibility without deleting desired state. The dedicated
`data_access/bigquery` driver exposes the service-account secret and rollout
field in the generic integration admin/API schema, so operators can create or
patch the integration with:

```json
{
  "config": {
    "service_account_json": "{...}",
    "apply_mode_enabled": "true"
  }
}
```

`service_account_json` remains encrypted as the driver secret. The rollout
field is public integration config; provider permissions remain bounded by
Terraform-managed dataset IAM.

DAX-033 is the first reconciler-owned provider mutation slice. The runner
`apply_present_reconcile_compiled_effects(...)` consumes pending compiled
effects, ignores absent effects for the revoke-side runner, reuses the DAX-032
gate for present effects, reads the provider etag for each allowed dataset
locator, and sends an add-only dataset IAM patch through the dedicated
data-access BigQuery driver. Successful or already-applied provider results
mark effects `applied`, write `apply_present` run-history rows, and refresh
binding/request reconcile roll-ups. Conflicts and provider errors remain
`retryable`; request handlers and Feishu callbacks still do not mutate
BigQuery directly.

DAX-034 adds the revoke-side reconciler-owned provider mutation slice. The
runner `apply_absent_reconcile_compiled_effects(...)` consumes pending compiled
effects, considers only absent effects whose semantic binding is already
`revoked_pending` or `revoked`, reuses the DAX-032 gate for those effects, reads
the provider etag for each allowed dataset locator, and sends a remove-only
dataset IAM patch for the exact compiled Google Group dataset-reader grant.
Successful or already-absent provider results mark effects `applied`, promote
`revoked_pending` bindings to `revoked`, write `apply_absent` run-history rows,
and refresh binding/request reconcile roll-ups. Conflicts and provider errors
remain `retryable`; disabled config and malformed active binding absent effects
make no provider call.

DAX-035 pins the already-present adoption contract for approved Helix
bindings. If the present runner's add-only patch finds the exact provider
member/role already on the dataset and the driver returns
`already_applied=True`, the compiled effect is still the authority: it records
`already_present_at_first_apply=true`, the apply-present result/audit exposes
that marker, and the effect remains Helix-managed through the normal applied
state/run-history path. Later revoke does not "forgive" the adopted provider
grant; `revoke_data_access_binding(...)` compiles an absent effect for the same
provider member, provider role, and dataset locator, and the DAX-034 absent
runner removes exactly that grant while preserving unrelated ACL members.

DAX-036 pins failure semantics for the apply runners. Provider
auth/read failure on the present path marks the selected effect retryable/error,
rolls the active binding and approved request to retryable, audits a redacted
error, and makes no `set_dataset_iam` call/provider write. Partial absent
failure is locator-scoped: successful locators can terminalize their own absent
effects and revoked-pending bindings, while failed locators remain
`revoked_pending`/retryable with their own provider error and etag evidence.
The proof prevents mass-revoke semantics across mixed batches and adds no new
surface.

DAX-037 pins the end-to-end apply audit trail. A full approved lifecycle now
has executable coverage from request submission through Feishu callback,
binding/effect creation, apply-present, semantic revoke, and apply-absent. The
durable evidence spans `audit_logs` and `data_access_effect_runs`: request,
callback, binding, compile, apply, revoke, provider outcome, provider etag, and
trace-id evidence must all be present. The test also proves the same path does
not list provider datasets/resources, touch project IAM, or route through
generic cloud grants.

DAX-038 was the former M4 Group request-authoring surface. It is retired for
new requests: `/my/data-access/requests` no longer offers a Group principal,
and `POST /api/v1/data-access/requests/group-grants` is not registered.
Historical Group semantic state remains available to its callback, reconciler,
revoke, and read-only history paths; this retirement does not create a binding,
effect, absent effect, provider call, or BigQuery mutation.

DAX-039 adds the read-only request detail surface. Authorized readers can open
`/my/data-access/requests/:requestId` through
`GET /api/v1/data-access/requests/{request_id}` to see Feishu status, per-item
display state, staleness reason, compiled preview, binding/effect apply status,
and recent provider run history. The endpoint is allowed for global admins,
the original requester, current owners of the requested Google Group, or
resolved owners of requested data-access resources. Resource-owner-only readers
receive a projection limited to their owned items/effects/runs, with aggregate
request error, request trace, and freeform business reason suppressed so
multi-dataset requests cannot leak unrelated dataset context. Other callers
receive a 404 without dataset/group leakage. The backend owns the display-state
mapping so the UI does not infer policy from raw provider strings, and this
path still does not call BigQuery.

DAX-040 adds the read-only Google Group access surface. Current Google Group
owners and global admins can open `/my/data-access/group-access` through
`GET /api/v1/data-access/principals/{principal_email_group_id}/access` to see
Helix-owned BigQuery dataset `read` bindings for one active Google Group:
active, pending-apply, failed, revoke-pending, and revoked-history rows are
projected from semantic bindings, compiled effects, resources, entitlements,
and effect-run history. The endpoint exposes `principal_type="google_group"`
and canonical provider output (`group:<email>` +
`roles/bigquery.dataViewer`) only from Helix state. It does not inspect raw
provider grants, does not expose personal-principal access, and returns a no-leak 404
for unowned groups.

DAX-041 adds the admin failed-apply operations surface. Global admins can open
`/admin/data-access/failed-apply` through
`GET /api/v1/data-access/admin/failed-apply` to inspect failed/retryable
compiled effects from Helix state only, then call
`POST /api/v1/data-access/admin/failed-apply/{effect_id}/retry` for one effect.
The retry operation records an `admin_retry` effect-run row, audits
`data_access.failed_apply.retry`, moves failed effects back to `retryable`,
and delegates the actual provider attempt to the existing apply-mode-gated
apply-present/apply-absent reconciler. It creates no new Feishu approval and
does not let request handlers call BigQuery driver/API primitives directly.

DAX-042 adds the read-only reconciler run-history operations surface. Global
admins can open `/admin/data-access/reconcile-runs` through
`GET /api/v1/data-access/admin/reconcile-runs` to inspect V1 BigQuery dataset
`read` effect-run rows joined to current semantic effect/binding/resource and
Google Group context. The endpoint filters to canonical `group:` provider
members, `roles/bigquery.dataViewer`, dataset resources, and `read:v1`
entitlements, then exposes run id, mode, status, provider outcome/error/etag,
trace id, timestamps, and current effect/binding status. It reads
Helix-owned run history only, does not call provider APIs, and does not create
a second retry or mutation authority.

DAX-043 makes catalog-missing suspensions and deleted-group revocations visible
as backend-owned notices rather than frontend policy inference. Global admins
can see suspended BigQuery dataset rows in
`GET /api/v1/data-access/requestable-resources`, but those rows carry
`availability="catalog_missing_suspended"` / `notice_code="catalog_missing_suspension"`
and remain unrequestable through the existing resource validator before Feishu
state is created. Employee request authors still see only active/requestable
resources. Group-access rows now expose resource status, requestability,
binding revoke reason, and notice code; `group_deleted_without_bridge`
revocations are visible to global admins even when the Google Group row is no
longer active, while employees keep the no-leak 404 boundary for deleted
groups. The notices are Helix semantic state only and do not read provider
grants or create a new mutation path.

DAX-044 makes the M4 localization gate executable for the data-access frontend.
`npm run lint` now runs `check-data-access-i18n.mjs`, which scans every
`web/src/pages/dataAccess/*.tsx` page for hardcoded visible JSX, Ant Design, and
message strings; verifies static `t(...)` keys in `en-US`, `zh-CN`, and
`ja-JP`; checks dynamic `dataAccess.*` namespaces; and enforces flat
`dataAccess.*` key parity across the three locale files. This is a guardrail
only and adds no user workflow, API, schema, provider call, approval path,
authorization rule, or mutation authority.

DAX-045 starts the M5 catalog-federation cutover by making the durable
projection path executable:
`catalog feed -> data_access_resource -> request validator/compiler`.
`catalog_feed.py` validates catalog-shaped BigQuery dataset entries, rejects
table/project/freeform locators plus duplicate locators/catalog ids, verifies resolved owner
ids against Helix identity, and upserts `source='catalog'` resources by
integration plus locator. Projection writes the row-local owner authority,
display label, per-dataset description (`description` input -> `notes` row),
labels, requestability, lifecycle status, source version/etag, and a source
payload digest into `data_access_resources`, then audits
`data_access.catalog_feed.project` with created/updated/unchanged outcomes.
Request handlers, Feishu callbacks, revoke services, and compilers still do not
raw-read catalog feed input; they read the projected resource row.

For production bootstrap before a catalog connector exists, operators can run
`server/scripts/seed_data_access_resources.py` inside the backend environment
with a JSON inventory. The script resolves `owner_email` to
`identity_persons.id`, rejects unresolved owners by default, and feeds
`project_catalog_dataset_feed(...)`; it does not call BigQuery or create
approval requests. Example inventory entry:

```json
{
  "project_id": "gcp-digital-bi-dev",
  "dataset_id": "dwh_core",
  "owner_email": "owner@pingcap.com",
  "display_name": "DWH Core",
  "description": "Curated BI warehouse core dataset.",
  "labels": {"risk": "finance"},
  "requestable": true
}
```

DAX-047 makes catalog-missing suspension part of that same projection path.
When an authoritative catalog feed omits a previously active
`owner_source='catalog'` BigQuery dataset row for the integration, the projector
marks the existing resource `status='suspended'`, returns/audits a
`catalog_missing_suspended` outcome, and leaves bindings/effects untouched. New
request authoring is blocked by the existing inactive-resource validator and
DAX-043 notices; existing access is not auto-revoked and no provider mutation
path is created.

DAX-048 makes confirmed removal the separate revoke-producing lifecycle path.
When catalog projection reports `status='deleted'`, or a trusted admin/system
caller confirms a resource was removed, Helix marks the resource
`status='deleted'` / `requestable=false`, moves active bindings to
`revoked_pending`, and compiles absent effects from the original present-effect
target/member snapshots. The transition is audited as
`data_access.resource.removal_confirmed`, is idempotent on replay, creates no
Feishu approval request, and still leaves BigQuery mutation to the reconciler.

DAX-049 closes deterministic bootstrap replacement. Catalog and bootstrap input
may carry optional `catalog_id`, projected into
`data_access_resources.catalog_resource_id`. Catalog/bootstrap parsers reject
duplicate catalog ids; the feed matches rows by catalog id before falling back to
provider locator and audits the match strategy. Resolved owner matches replace
bootstrap rows in place as catalog rows while preserving resource id, requests,
bindings, effects, audit, and run history. Catalog-id matches that would retarget
a bootstrap- or catalog-owned row with existing request/binding/effect/run
history to a different provider locator suspend new authoring, keep the old
provider locator and history intact, and audit `bootstrap_locator_drift_suspended`
or `catalog_locator_drift_suspended` plus `locator_drift_count` for admin
resolution. Owner mismatches suspend new authoring, clear runtime resource-owner
authority, leave the row in bootstrap ownership for admin resolution, and create
no Feishu approval, revoke intent, absent effect, or provider mutation.

DAX-050 adds the read-only catalog cutover validation report. Global admins can
call `GET /api/v1/data-access/admin/cutover-validation` to classify projected
resource rows as bootstrap remaining/requestable/historical, unmatched
bootstrap, catalog-fed active, suspended, duplicate locator, duplicate catalog
id, locator-drift-with-history, owner-mismatch, unresolved/fallback owner,
invalid, or unsafe unmatched-bootstrap revoke evidence. The report returns full
category counts, blocking counts, row-level evidence, semantic history counts,
and `cutover_ready` only when blocking categories are zero. It reads only
`data_access_resources`, request items, bindings, compiled effects, and effect
runs; it does not read raw catalog/bootstrap input, call BigQuery, compile
absent effects, or revoke provider grants.

## Canonical Names

Later code must use constants for these values instead of open-coded aliases.

| Concept | Canonical value | Forbidden examples |
| --- | --- | --- |
| Domain | `data_access` | `cmdb_data_access`, `bq_access` |
| Provider | `bigquery` | `gcp`, `google_cloud` |
| Resource type | `dataset` | `table`, `project` |
| Principal type | `google_group` | `user`, `person`, `service_account` |
| Entitlement | `read` | `viewer`, `roles/bigquery.dataViewer` |
| Approval flow | `data_access_group_grant` | `cloud_scope_request`, `cmdb_data_access` |
| Provider role | `roles/bigquery.dataViewer` | `roles/bigquery.jobUser`, project roles |

`read` is the Helix semantic entitlement. Provider role strings are compile
output, not request input.

The backend constants live in
[`server/app/domains/data_access/constants.py`](../../server/app/domains/data_access/constants.py).
That module is the code-level vocabulary source for follow-up DAX schemas,
services, tests, approval-flow config, compiler output, and reconciler code.
The durable entitlement catalog lives in `data_access_entitlements`; DAX V1
seeds exactly one requestable row, `read:v1`, whose provider effect is
`group:` + `roles/bigquery.dataViewer`. The catalog has no resource or locator
override columns.

## Current-State Inventory

These are the code/doc anchors DAX-001 relies on. Re-pin exact line numbers
before implementation if nearby files move.

| Surface | Current evidence | Current behavior | V1 target disposition |
| --- | --- | --- | --- |
| Boot/doc authority | [`CLAUDE.md`](../../CLAUDE.md), [`PLATFORM.md`](PLATFORM.md), [`DECISIONS.md`](DECISIONS.md) | Feature/fix PRs must keep roadmap and architecture docs current; structural decisions live in ADRs. | This document + ADR are the binding data-access contract; issues under #809 are executable slices, not new authorities. |
| Google Group mirror | [`email_group/__init__.py`](../../server/app/domains/email_group/__init__.py), [`email_group/models.py`](../../server/app/domains/email_group/models.py), [`google_workspace.py`](../../server/app/integrations/drivers/google_workspace.py), [`google_handlers.py`](../../server/app/scheduled_jobs/google_handlers.py) | Helix mirrors Google Groups into `email_groups` and owners into `email_group_owners`; Google remains membership source of truth; groups soft-delete with `status='deleted'`. Current group sync keys rows by primary email and also stores `google_group_id` plus aliases. | Data access consumes group existence/deletion/owner facts for validation and system revocation. It must not reconcile membership or create groups. It must treat `email_groups.id` as the semantic principal key and provider email as compile output. |
| Existing Google Group request flow | [`email_group/service.py`](../../server/app/domains/email_group/service.py), [`soc2_email_group/service.py`](../../server/app/domains/soc2_email_group/service.py), [`PLATFORM.md` external-origin approval notes](PLATFORM.md) | Mail-group and SOC2 group flows mutate Google Group membership directly for their own products. | These remain separate `email_group` capabilities. DAX V1 only grants BigQuery dataset IAM to Google Group principals; request-to-join-group stays outside V1. |
| Feishu approval transport | [`approval/service.py`](../../server/app/approval/service.py), `integration_approval_flows`, [`PLATFORM.md`](PLATFORM.md) | Approval domain owns Feishu instance lookup, callback dispatch, external-origin handling, and callback authenticity. | Data access maps to `data_access_group_grant`, owns semantic state/staleness, and must not let Feishu widget shape become durable policy. |
| Existing BigQuery usage | [`aws_cloud.py`](../../server/app/integrations/drivers/aws_cloud.py), [`gcp.py`](../../server/app/domains/cmdb/providers/gcp.py), [`cloud-lifecycle-ops.md`](../ops/cloud-lifecycle-ops.md) | BigQuery is already used for billing/cost read paths and has known scope/permission footguns (`bigquery` scope vs readonly, dataset existence probes). | Those paths are evidence and implementation lessons only. They do not become the data-access runtime path or desired-state store. |
| Existing CMDB/GCP cloud grants | [`gcp_cloud.py`](../../server/app/integrations/drivers/gcp_cloud.py), [`cmdb/lifecycle_service.py`](../../server/app/domains/cmdb/lifecycle_service.py), [`cloud-lifecycle-ops.md`](../ops/cloud-lifecycle-ops.md) | CMDB cloud grants manage cloud scopes/resources and include project-level IAM behavior for GCP. | Data access must use a dedicated `kind='data_access'`, `provider='bigquery'` driver and must not route dataset IAM through generic cloud grants. |
| Data-access constants | [`data_access/constants.py`](../../server/app/domains/data_access/constants.py) | DAX-002 ships canonical V1 vocabulary, field-name constants, and alias-rejection helpers. | Follow-up schemas/services must import these constants instead of open-coding aliases. |
| Data-access approval-flow mapping | [`data_access/approval_flow.py`](../../server/app/domains/data_access/approval_flow.py), `integration_approval_flows` | DAX-003 resolves the active `data_access_group_grant` mapping through the approval-flow table and fails closed when missing/inactive. It rejects `accept_external_origin=True` so Feishu direct submissions cannot bypass Helix validation. | Future request submission code must call this resolver before creating a Feishu instance. Helix still does not author Feishu templates. |
| Data-access bootstrap input | [`data_access/bootstrap.py`](../../server/app/domains/data_access/bootstrap.py) | DAX-004 defines the ops-only, side-effect-free parser for catalog-shaped BigQuery dataset bootstrap entries. It rejects table/project/freeform locators and owner-authority ambiguity. DAX-049 accepts optional `catalog_id` so bootstrap rows can later match the durable catalog identity, and rejects duplicate non-empty catalog ids within a bootstrap batch. | Future bootstrap ingestion must use this shape before projecting rows; it is transition input, not a manual catalog or authorization read path. |
| Data-access personal employee request | [`router.py`](../../server/app/domains/data_access/router.py), [`service.py`](../../server/app/domains/data_access/service.py), [`DataAccessRequestPage.tsx`](../../web/src/pages/dataAccess/DataAccessRequestPage.tsx), [`data_access_personal_grant`](../../deploy/feishu/approval-definitions/data_access_personal_grant) | DAX personal requests add `POST /api/v1/data-access/requests/personal-grants` and the default 个人 tab. Every active employee may browse the same active/requestable dataset projection; the Group selector remains separately owner/member-scoped and Group submit still validates Group authority. The personal endpoint accepts datasets, reason, detail URL, and `Idempotency-Key` only—never a target employee/email. It derives a `google_user` principal from the authenticated person's UUID and normalized work email, creates/reuses that principal transactionally, and uses its dedicated Feishu definition. Approval and callback revalidate the active requester and UUID/email snapshot before compiling `user:<work_email>` / `userByEmail`. | No direct BigQuery call is reachable from the page, HTTP route, submission, or callback. The scheduled DAX handler first marks active personal bindings stale on identity departure/email change and compiles exact absent effects. Provider mutation additionally requires normal DAX apply mode and default-closed `personal_apply_enabled`; the reconciler remains the only writer. |
| Data-access visible notices | [`router.py`](../../server/app/domains/data_access/router.py), [`schemas.py`](../../server/app/domains/data_access/schemas.py), [`DataAccessRequestPage.tsx`](../../web/src/pages/dataAccess/DataAccessRequestPage.tsx), [`DataAccessGroupAccessPage.tsx`](../../web/src/pages/dataAccess/DataAccessGroupAccessPage.tsx) | DAX-043 extends requestable-resource and group-access responses with server-owned availability/notice fields. Global admins can see suspended catalog-missing dataset rows in the request picker with `catalog_missing_suspension`; employees still see only active/requestable rows. Group-access rows expose `resource_status`, `resource_requestable`, `revoke_reason`, and notice codes for catalog suspensions and `group_deleted_without_bridge` revocations; deleted groups with DAX bindings are inspectable only by global admins. | Notices are explanatory projections of Helix semantic state. The existing resource validator still blocks suspended datasets before Feishu/request/binding/effect state is created, and deleted-group reads keep the employee no-leak 404 boundary. Frontend pages render notice codes and disable unavailable rows; they do not infer provider authority or call provider APIs. |
| Data-access frontend i18n guard | [`check-data-access-i18n.mjs`](../../web/scripts/check-data-access-i18n.mjs), [`package.json`](../../web/package.json), [`en-US.ts`](../../web/src/i18n/en-US.ts), [`zh-CN.ts`](../../web/src/i18n/zh-CN.ts), [`ja-JP.ts`](../../web/src/i18n/ja-JP.ts) | DAX-044 wires a DAX-specific localization guard into `npm run lint`. It scans the data-access page TSX files for hardcoded visible text, checks all static `t(...)` keys across the three locale files, verifies dynamic `dataAccess.*` namespaces, and enforces `dataAccess.*` namespace parity across en/zh/ja. | Localization guard only. It adds no runtime data authority, provider call, approval path, mutation path, or new user surface; UI text remains localized while backend state remains the source of truth. |
| Data-access catalog feed projection | [`catalog_feed.py`](../../server/app/domains/data_access/catalog_feed.py), [`test_catalog_feed.py`](../../server/tests/test_data_access/test_catalog_feed.py) | DAX-045 adds the first durable catalog feed ingestion path. It validates catalog-shaped BigQuery dataset entries, rejects table/project/freeform/duplicate locator inputs, verifies resolved owner ids, and idempotently upserts `source='catalog'` rows with locator, owner authority, display label, business description in `notes`, labels, requestability/status, source version/etag, source digest, and audit outcomes. Bootstrap rows with exact locator matches are replaced in place. DAX-047 makes omitted active catalog-owned rows suspend idempotently with `catalog_missing_suspended` / `suspended_count` audit evidence. DAX-048 treats explicit catalog `status='deleted'` as provider-confirmed removal, making the resource non-requestable/deleted and delegating binding revoke-pending/absent-effect compilation to the service path. DAX-049 matches by `catalog_id` before provider locator, preserves semantic history on owner-resolved bootstrap replacement, suspends catalog-id locator drift with preserved history instead of mutating the live locator away from compiled-effect targets, and suspends owner-mismatch rows without granting either bootstrap or catalog owner revoke authority. | Feed input is import/projection material only. Request validation, callbacks, revokes, UI, and compilers read `data_access_resources`; they must not import raw feed input as a second owner/identity source. `notes` is the persisted business-description fact used by API search, request UI, and Feishu payloads. Catalog-missing suspension blocks new authoring through the resource validator but does not create revoke intent, absent effects, Feishu approvals, or provider calls. Confirmed deleted status is the explicit removal signal and creates semantic absent effects only for the reconciler. Bootstrap owner mismatch and catalog-id locator drift with preserved history are admin-resolution states, not provider mutation paths. |
| Data-access cutover validation report | [`cutover_report.py`](../../server/app/domains/data_access/cutover_report.py), [`router.py`](../../server/app/domains/data_access/router.py), [`test_router.py`](../../server/tests/test_data_access/test_router.py) | DAX-050 adds global-admin `GET /api/v1/data-access/admin/cutover-validation` as the cutover readiness report over projected resources and semantic history. It lists/counts bootstrap remaining/requestable/historical, unmatched bootstrap, catalog-fed active, suspended, duplicate locator, duplicate catalog id, locator-drift-with-history, owner-mismatch, unresolved/fallback owner, invalid, and unsafe unmatched-bootstrap revoke evidence. `cutover_ready` is true only when blocking categories are zero; non-requestable/suspended bootstrap rows remain visible as provenance. | The report is read-only. It reads `data_access_resources`, request items, bindings, compiled effects, and effect runs only; it does not read raw catalog/bootstrap input, call BigQuery, create approvals, compile absent effects, revoke provider grants, or become a second dataset/owner authority. |
| Data-access resources | [`models.py`](../../server/app/domains/data_access/models.py), [`331_data_access_resources.py`](../../server/alembic/versions/331_data_access_resources.py), [`337_data_access_resource_labels.py`](../../server/alembic/versions/337_data_access_resource_labels.py), [`338_data_access_resource_catalog_id.py`](../../server/alembic/versions/338_data_access_resource_catalog_id.py) | DAX-006 adds `data_access_resources` for BigQuery dataset projection, requestability/status, catalog/bootstrap source version/etag, row-local owner authority state, and nullable `notes`. DAX-045 adds catalog `labels` metadata. DAX-049 adds nullable `catalog_resource_id` as the stable catalog identity used for deterministic bootstrap replacement. Catalog business descriptions are stored in existing `notes`; no additional schema column is required. Duplicate active locators are DB-rejected per integration; unresolved/fallback owner rows are representable without owner authority. | Request/resource validation must read dataset identity and resource-owner authority from this table, not raw catalog/bootstrap inputs. API/search/UI/Feishu read the projected `notes` value and must not read raw inventory input as a second description source. |
| Data-access entitlements | [`models.py`](../../server/app/domains/data_access/models.py), [`332_data_access_entitlements.py`](../../server/alembic/versions/332_data_access_entitlements.py) | DAX-007 adds `data_access_entitlements` and idempotently seeds `read:v1` as the only requestable semantic entitlement. It maps to Google Group dataset read effects and deliberately has no per-resource override path. | Request/binding/compiler code must reference the semantic `read:v1` catalog row, not raw provider roles or resource-specific entitlement variants. |
| Data-access requests | [`models.py`](../../server/app/domains/data_access/models.py), [`333_data_access_requests.py`](../../server/alembic/versions/333_data_access_requests.py) | DAX-008 adds `data_access_requests` and `data_access_request_items` for pending semantic intent. Requests store `email_groups.id` as the principal plus Google group id / primary email snapshots; items store resource ids plus locator/source/owner snapshots for stale callback checks. | Request submission and callback code must use these rows as semantic intent and staleness evidence only; they must not treat snapshots or aliases as authority and must not mutate BigQuery. |
| Data-access bindings | [`models.py`](../../server/app/domains/data_access/models.py), [`334_data_access_bindings.py`](../../server/alembic/versions/334_data_access_bindings.py) | DAX-009 adds `data_access_bindings` for approved semantic desired state. Current uniqueness is resource id + `email_groups.id` + entitlement id; primary email is only a provider snapshot so revoked history and rename bridges can compile intentional old-email absent effects. | Binding readers must treat resource/group/entitlement ids as the semantic key. Provider email snapshots are compile/revoke evidence, not group authority, and no provider mutation happens from binding writes. |
| Data-access compiled effects | [`models.py`](../../server/app/domains/data_access/models.py), [`335_data_access_compiled_effects.py`](../../server/alembic/versions/335_data_access_compiled_effects.py) | DAX-010 adds `data_access_compiled_effects` for provider-specific desired output with target locator, `group:<primary_email_snapshot>`, provider role, desired state, entitlement/resource/compiler provenance, managed reason, adoption marker, status, and provider outcome fields. | Effects are reconciler input/audit evidence. They do not apply BigQuery IAM by themselves; provider member snapshots are compile output, not principal authority. |
| Data-access resource validator | [`service.py`](../../server/app/domains/data_access/service.py), [`test_resource_validator.py`](../../server/tests/test_data_access/test_resource_validator.py) | DAX-011 adds `validate_requestable_bigquery_dataset_resource(...)`, which accepts only active, requestable, canonical BigQuery dataset rows from `data_access_resources` and returns a request-intent snapshot. Missing/non-federated, non-BigQuery, table, inactive, and non-requestable resources fail before Feishu submission. Resource-owner-required paths fail closed unless the projected row has resolved owner authority. | Request submission must call this validator before creating Feishu instances. Runtime owner authority remains `data_access_resources.owner_person_id` only; raw source inputs are provenance, not an authorization read path. |
| Data-access principal validator | [`service.py`](../../server/app/domains/data_access/service.py), [`test_principal_validator.py`](../../server/tests/test_data_access/test_principal_validator.py) | DAX-012 adds `validate_google_group_principal(...)`, which resolves group primary email/alias input or an `email_groups.id` to exactly one active Google Group row. Alias input resolves to the primary group snapshot but aliases never become provider members. Deleted rows bridge only when exactly one active row has the same `google_group_id`; unknown groups, user/person/service-account principal types, deleted groups without a bridge, missing `google_group_id`, alias collisions, and duplicate active rows for one `google_group_id` fail closed. | Request submission and callback binding creation must call this validator before using a principal. The durable semantic key remains `email_groups.id`; `google_group_id` is rename continuity; `group:<primary_email_snapshot>` is provider output. |
| Data-access requester authorization | [`service.py`](../../server/app/domains/data_access/service.py), [`test_requester_authorization.py`](../../server/tests/test_data_access/test_requester_authorization.py) | DAX-013 adds `authorize_requester_for_google_group_principal(...)`, which accepts only a validated Google Group principal and authorizes the requester as either the current owner row in `email_group_owners` for the resolved `email_groups.id`, or as global admin. Primary email and aliases are not authorization keys. The role check honors request-scoped view-role narrowing before reading stored roles asynchronously. | Request submission must call this gate after principal validation and before Feishu creation. It must not read ownership from group emails/aliases and must not mutate BigQuery. |
| Data-access dry-run compiler | [`service.py`](../../server/app/domains/data_access/service.py), [`test_dry_run_compiler.py`](../../server/tests/test_data_access/test_dry_run_compiler.py) | DAX-014 adds `compile_bigquery_dataset_read_dry_run(...)`, which consumes validated resource/principal snapshots plus the canonical `read:v1` entitlement row and returns an immutable preview for `bigquery` dataset `group:<primary_email_snapshot>` + `roles/bigquery.dataViewer`, desired state `present`, provenance snapshots, compiler version, and managed reason. It derives provider member output from the primary-email snapshot every time and rejects unsupported provider/resource/entitlement/member-prefix/provider-role/status/requestability combinations. | Request submission/Feishu payload code must use this preview before Feishu creation, then callback/binding code may persist equivalent compiled effects after approval. Provider member/role strings remain compiler output, not request input, and the dry run does not mutate BigQuery. |
| Data-access Feishu payload builder | [`service.py`](../../server/app/domains/data_access/service.py), [`test_feishu_payload.py`](../../server/tests/test_data_access/test_feishu_payload.py) | DAX-015 adds `build_feishu_submission_payload(...)`, which takes the resolved `data_access_group_grant` flow, validated dataset resources, validated Google Group principal, canonical `read:v1` entitlement, business reason, and detail URL. The v3 Feishu submission fields are human-readable: request summary, requested access, dataset list with description when present, requester note/business reason, and Helix detail URL. The same payload exposes separate audit fields containing semantic intent, dataset owner ids/context, dataset count, and DAX-014 compiled previews for durable Helix source payload storage. | Request submission must use this builder before creating a Feishu instance. Feishu approvers must not receive raw JSON/UUID-heavy semantic intent, owner context, compiled preview fields, or logging-style payload/compiler context; those remain in `approval_requests.source_payload` for callbacks/audit. The builder does not write `data_access_requests`, `data_access_request_items`, bindings, or compiled effects. Feishu widget ids/templates stay outside the data-access authority boundary. |
| Data-access request submission service | [`service.py`](../../server/app/domains/data_access/service.py), [`test_request_submission.py`](../../server/tests/test_data_access/test_request_submission.py) | DAX-016 adds `submit_group_grant_request(...)`, the first stateful request path. It resolves the active `data_access_group_grant` flow, validates canonical `read:v1`, validates active BigQuery dataset resources with owner authority, validates an active Google Group principal, authorizes the requester, builds the DAX-015 payload and configured Feishu form, resolves requester Feishu user id, creates `data_access_requests` / `data_access_request_items`, creates the Feishu instance, creates the linked `approval_requests` row, and writes an audit entry. Idempotency keys replay the existing request without another Feishu instance. | Submission stops at `pending_feishu` and `approval_requests.status='pending'`. It creates no bindings/effects and still does not mutate BigQuery. Feishu widget ids remain approval-flow config, and persisted source payloads contain no secrets or alias-as-member authority. |
| Data-access approval callback transition | [`approval/service.py`](../../server/app/approval/service.py), [`service.py`](../../server/app/domains/data_access/service.py), [`test_request_submission.py`](../../server/tests/test_data_access/test_request_submission.py) | DAX-017 registers the `data_access_group_grant` Feishu callback handler. It locks the linked request, verifies the approval-request/request/Feishu-instance binding, records latest Feishu detail, settles rejected/cancelled callbacks without effects, and on approved callbacks revalidates the canonical `read:v1` entitlement, active requester, requester authorization, Google Group identity, and every dataset locator/source/owner snapshot. Fresh approvals create one active binding and one pending compiled effect per item. Duplicate callbacks are idempotent. Stale resource owner/source changes, invalid/deleted/ambiguous group identity, mismatched instance/request binding, stale requester/entitlement state, and active binding conflicts mark the request `stale_requires_resubmit`, audit the refusal, and create no bindings/effects. Same-`google_group_id` primary-email rename is allowed and recompiles provider member output to the current primary email. | Callback handlers create only Helix semantic state and compiled effects. They still do not call BigQuery; the reconciler remains the only provider mutation owner. |
| Data-access callback staleness hardening | [`service.py`](../../server/app/domains/data_access/service.py), [`test_request_submission.py`](../../server/tests/test_data_access/test_request_submission.py) | DAX-018 makes callback staleness explicit around the DAX-017 transition. DAX-046 names catalog `source_version` / `source_etag` drift as `resource_catalog_version_stale` while leaving owner/locator drift as `resource_context_changed`. Approved callbacks now refuse missing/invalid/duplicated compiled previews, stale compiler-version snapshots, entitlement-version mismatch, resource requestability changes, catalog source-version/etag changes, missing `google_group_id`, deleted groups without a bridge, duplicate active group identity, stale requester state, and stale entitlement state before binding/effect writes. Deleted-to-active same-`google_group_id` rename bridges are accepted only after the active bridge row revalidates and the requester is authorized for that active row. | Stale callbacks mark `stale_requires_resubmit`, audit the refusal with changed field/current/snapshot evidence, and create no bindings/effects. These checks still do not mutate BigQuery. |
| Data-access revoke service | [`service.py`](../../server/app/domains/data_access/service.py), [`test_revoke_service.py`](../../server/tests/test_data_access/test_revoke_service.py) | DAX-019 adds Feishu-free semantic revoke intent. `revoke_data_access_binding(...)` authorizes current Google Group owners, resolved resource owners, and global admins, marks active bindings `revoked_pending`, and compiles absent effects with `managed_reason='revoke_request'`. `revoke_invalid_google_group_bindings(...)` consumes email-group deleted/invalid read-model signals, revokes deleted-without-bridge principals idempotently, treats active duplicate same-`google_group_id` identity as an invalid signal for the exact principal binding, blocks ambiguous deleted-row rename bridges, and handles a single same-`google_group_id` primary-email rename bridge by relinking or superseding active bindings, compiling old-email absent/new-email present bridge effects for active survivors, and respecting revoked-pending survivors without regranting them. DAX-048 adds `confirm_data_access_resource_removed(...)` for trusted provider/admin removal confirmation: it marks the resource deleted/non-requestable, moves active resource bindings to revoked-pending, compiles absent effects, and audits the transition. Absent effects use the original present compiled-effect target when available. | Revocation changes semantic desired state only. It creates no Feishu approval request, HTTP/MCP endpoint, scheduled job, BigQuery driver call, or provider mutation from the service path; only the DAX-034 reconciler apply-absent runner may apply absent effects. |
| Data-access audit events | [`service.py`](../../server/app/domains/data_access/service.py), [`test_request_submission.py`](../../server/tests/test_data_access/test_request_submission.py), [`test_revoke_service.py`](../../server/tests/test_data_access/test_revoke_service.py) | DAX-020 adds traceable audit events around the M1 state path: request submission preview compile output, callback receipt, binding creation, callback compile output, revoke compile output, system cleanup compile output, and rename-bridge compile output. Existing approved/rejected/refused/revoke/system-blocked audit rows now sit in the same trace chain as the structured effect payloads they describe. | Audit rows are observability only. They do not create a second source of truth for provider state and do not apply BigQuery IAM; compiled effects remain the reconciler input. |
| Data-access BigQuery driver registration | [`driver_base.py`](../../server/app/integrations/driver_base.py), [`bigquery_data_access.py`](../../server/app/integrations/drivers/bigquery_data_access.py), [`test_registry.py`](../../server/tests/test_integrations/test_registry.py), [`test_driver_registration.py`](../../server/tests/test_data_access/test_driver_registration.py) | DAX-021 adds the dedicated `DataAccessDriver` mixin and registers `kind='data_access'`, `provider='bigquery'` through the normal integration registry import path. DAX-021 was registration-only: no credential fields, Google SDK imports, cloud scope/grant methods, or live connection test shipped in that slice. | This is identity/registry wiring only. It does not run a reconciler, mutate BigQuery, or route through the existing GCP cloud driver. |
| Data-access driver IAM interface | [`driver_base.py`](../../server/app/integrations/driver_base.py), [`test_driver_interface.py`](../../server/tests/test_data_access/test_driver_interface.py) | DAX-022 defines the locator-scoped interface for future provider reads/applies: `get_dataset_iam(project_id, dataset_id)` and `set_dataset_iam(project_id, dataset_id, expected_etag, policy_patch)`, plus dataset IAM policy/binding/patch/update-result dataclasses. | This is an interface contract only. It adds no credential loading, Google SDK import, provider call, scheduled job, reconciler, or mutation path; the BigQuery registration driver still raises `NotImplementedError` at runtime. |
| Data-access BigQuery IAM read path | [`bigquery_data_access.py`](../../server/app/integrations/drivers/bigquery_data_access.py), [`test_driver_read_path.py`](../../server/tests/test_data_access/test_driver_read_path.py) | DAX-023 adds required secret `service_account_json` config, lazy Service Account credential loading with the BigQuery OAuth scope, strict dataset locator validation, and a mocked provider read path that calls BigQuery `get_dataset(..., dataset_view=DatasetView.ACL)` for exactly the requested locator. Unconditional dataset access entries are normalized into `DataAccessDatasetIamPolicy` role/member bindings; conditional entries and all raw access entries are retained in `raw_policy` for later reconcile/apply decisions. | This is read-only provider observation for one compiled-effect locator. It adds no dataset listing, query job, scheduled job, reconciler, provider write path, HTTP/MCP endpoint, UI, project IAM, table access, or CMDB/GCP cloud grant route. Auth/provider failures raise instead of returning an empty policy. |
| Data-access observe-mode reconciler runner | [`reconciler.py`](../../server/app/domains/data_access/reconciler.py), [`test_observe_reconciler.py`](../../server/tests/test_data_access/test_observe_reconciler.py) | DAX-024 adds `observe_reconcile_compiled_effects(...)`, which selects pending or retryable persisted compiled effects for one explicit data-access integration, optionally narrows to caller-selected effect ids, groups by compiled dataset locator, calls `get_dataset_iam(project_id, dataset_id)` once per selected locator, and records `observe_would_add`, `observe_would_remove`, `observe_unchanged`, or `observe_error` on each effect plus a `data_access.reconcile.observe` audit summary. Provider read errors mark selected effects retryable with an error. | Observe-only runner. It does not call `set_dataset_iam`, enumerate datasets, mutate BigQuery, create a scheduled job, add a lock/idempotency layer, create run-history tables, update binding/request rollups, create unmanaged-grant reports, or expose HTTP/MCP/UI surfaces. Follow-up DAX issues own those pieces and later provider mutation. |
| Data-access effect run history and reconcile rollups | [`models.py`](../../server/app/domains/data_access/models.py), [`reconciler.py`](../../server/app/domains/data_access/reconciler.py), [`336_data_access_effect_runs.py`](../../server/alembic/versions/336_data_access_effect_runs.py), [`test_observe_reconciler.py`](../../server/tests/test_data_access/test_observe_reconciler.py), [`test_effect_model.py`](../../server/tests/test_data_access/test_effect_model.py) | DAX-026 adds `data_access_effect_runs` plus binding/request reconcile roll-up fields. Each processed observe effect records run id, trace id, integration/effect/binding/request/resource ids, target locator, provider member/role, desired state, resulting effect status, provider outcome/etag/error, and timestamps. Binding/request roll-ups are derived from compiled effects: retryable/failed surfaces provider failures, pending remains pending, and successful observe rows roll up to `observed`. | Run history is evidence, not authority. It does not create bindings, infer unmanaged grants, schedule reconciler work, expose HTTP/MCP/UI surfaces, or mutate BigQuery. Request/binding roll-ups are visibility fields derived from effects; request approval state and binding semantic desired state remain separate. |
| Data-access unmanaged provider grant reporting | [`reconciler.py`](../../server/app/domains/data_access/reconciler.py), [`test_observe_reconciler.py`](../../server/tests/test_data_access/test_observe_reconciler.py) | DAX-027 extends observe-mode delta reporting with unmanaged provider-side grant evidence. For each selected federated BigQuery dataset locator, the runner compares provider `group:` + `roles/bigquery.dataViewer` grants from `get_dataset_iam` with Helix compiled effects for that selected resource/locator and returns/audits `unmanaged_grant_count` plus `unmanaged_grants`. Already-observed Helix-managed effects are still considered managed, so a later pending effect on the same locator does not mislabel them unmanaged. | Evidence-only report. Observation does not create bindings, adopt provider grants, write a new authority table, scan BigQuery broadly, include unsupported provider roles/principals, schedule work, expose HTTP/MCP/UI surfaces, or mutate BigQuery. Unsupported/non-dataset resource rows are ignored by the observe selection. |
| Data-access negative IAM and broad-scan guard proof | [`reconciler.py`](../../server/app/domains/data_access/reconciler.py), [`bigquery_data_access.py`](../../server/app/integrations/drivers/bigquery_data_access.py), [`driver_base.py`](../../server/app/integrations/driver_base.py), [`test_observe_reconciler.py`](../../server/tests/test_data_access/test_observe_reconciler.py), [`test_driver_interface.py`](../../server/tests/test_data_access/test_driver_interface.py) | DAX-028 adds executable guard tests proving the observe/driver path only calls locator-scoped dataset IAM for selected compiled effects and dataset locators. Sentinel drivers and static scans fail on project IAM, `bigquery.jobUser`, broad dataset/resource scan, generic cloud grants, and direct request/Feishu BigQuery calls. | Guard-only proof; no production code/surface/mutation, schema, scheduled job, HTTP/MCP/UI surface, table/single-user access, project IAM, `bigquery.jobUser`, broad scan, or cloud-lifecycle grant path is introduced. |
| Data-access BigQuery least-privilege credential contract | [`bigquery_data_access.py`](../../server/app/integrations/drivers/bigquery_data_access.py), [`test_driver_read_path.py`](../../server/tests/test_data_access/test_driver_read_path.py), [`test_driver_interface.py`](../../server/tests/test_data_access/test_driver_interface.py), this document's [BigQuery Reconciler Credential Least Privilege](#bigquery-reconciler-credential-least-privilege) section | DAX-029 pins the exact credential boundary required before apply mode: a dedicated `data_access/bigquery` service-account secret, BigQuery OAuth scope `https://www.googleapis.com/auth/bigquery`, and custom role `HelixDaxDatasetIamReconciler` containing only `bigquery.datasets.get`, `bigquery.datasets.getIamPolicy`, `bigquery.datasets.update`, and `bigquery.datasets.setIamPolicy`. Tests prove the driver uses the dedicated config secret and scope, the required permission tuple excludes dataset create/delete/list, job, and table-data permissions, and durable request/binding/effect/run-history tables have no service-account, private-key, OAuth, raw-policy, or raw-response columns. | Credential proof only. No project IAM role, `roles/bigquery.jobUser`, `bigquery.jobs.create`, dataset listing, table access, raw-provider policy persistence, HTTP/MCP/UI surface, scheduled job, or cloud-lifecycle grant path is introduced. |
| Data-access BigQuery dataset ACL canary planner | [`bigquery_data_access.py`](../../server/app/integrations/drivers/bigquery_data_access.py), [`test_driver_read_path.py`](../../server/tests/test_data_access/test_driver_read_path.py), [`test_driver_interface.py`](../../server/tests/test_data_access/test_driver_interface.py), this document's [BigQuery Dataset ACL Patch Behavior](#bigquery-dataset-acl-patch-behavior) section | DAX-030 adds a pure patch planner for the future provider write. It accepts BigQuery REST `access` arrays and current `google-cloud-bigquery` `access_entries` snapshots, accepts only unconditional `group:<email>` + `roles/bigquery.dataViewer` DAX changes, removes only matching DAX-managed entries, preserves unrelated REST entries byte-for-byte, converts current access-entry snapshots to REST shape when needed, and appends missing Google Group dataset-reader grants as `READER` + `groupByEmail`. Tests cover legacy `groupByEmail`, IAM-style `iamMember`, conditional entries, owners, writers, special groups, authorized views/routines, users, current access-entry snapshots, and non-DAX `jobUser`-like entries. | The DAX-030 slice was canary planning only; DAX-031 below wires the same planner to a guarded provider call with etag optimistic concurrency. No reconciler apply mode, project IAM, `roles/bigquery.jobUser`, broad scan, table access, query job, raw-provider policy persistence, scheduled job, HTTP/MCP/UI surface, or cloud-lifecycle grant path is introduced by this planner. |
| Data-access BigQuery apply/revoke driver method | [`bigquery_data_access.py`](../../server/app/integrations/drivers/bigquery_data_access.py), [`test_driver_read_path.py`](../../server/tests/test_data_access/test_driver_read_path.py), [`test_driver_interface.py`](../../server/tests/test_data_access/test_driver_interface.py), this document's [BigQuery Dataset ACL Patch Behavior](#bigquery-dataset-acl-patch-behavior) section | DAX-031 wires the DAX-030 planner to the provider call behind driver-local gates: `apply_mode_enabled` must be truthy before a BigQuery client is built, while dataset scope is enforced by Terraform-managed service-account IAM. When allowed, the driver re-reads the locator with ACL view and access policy version 3, compares provider etag with `expected_etag`, sends `If-Match`, patches only `UPDATE_ACL`, returns `already_applied=True` for no-op add/remove patches, and returns `conflict=True` for stale etags or provider 409/412. | Guarded driver primitive only. No scheduled reconciler apply runner, request handler mutation path, Feishu callback mutation path, HTTP/MCP/UI surface, project IAM, `roles/bigquery.jobUser`, broad scan, table access, query job, raw-provider policy persistence, or cloud-lifecycle grant path is introduced. DAX-032 below owns the runtime apply gate; DAX-033 and DAX-034 own applying present/absent effects through the reconciler. |
| Data-access reconciler apply-mode gate | [`apply_gate.py`](../../server/app/domains/data_access/apply_gate.py), [`reconciler.py`](../../server/app/domains/data_access/reconciler.py), [`test_observe_reconciler.py`](../../server/tests/test_data_access/test_observe_reconciler.py), [`bigquery_data_access.py`](../../server/app/integrations/drivers/bigquery_data_access.py), this document's [BigQuery Dataset ACL Patch Behavior](#bigquery-dataset-acl-patch-behavior) section | DAX-032 centralizes the apply-mode config parser and adds `resolve_apply_reconcile_gate(...)`. The gate reads the explicit enabled `data_access/bigquery` integration config, locks the bounded pending compiled-effect selection with the same `FOR UPDATE SKIP LOCKED` pattern, classifies each effect as `allowed`, `apply_mode_disabled`, or `invalid_apply_gate_config`, and writes a `data_access.reconcile.apply_gate` audit row with exact effect/resource selection and decisions. | Gate only. Effects, bindings, and requests remain unchanged; the helper does not call `set_dataset_iam`, does not build a BigQuery client, and does not enumerate provider resources. No scheduled job, provider write from the reconciler, HTTP/MCP/UI surface, project IAM, `bigquery.jobUser`, broad scan, table access, query job, raw-provider policy persistence, or cloud-lifecycle grant path is introduced. Dataset scope is managed by `infra/gcp-dax`. |
| Data-access apply-present reconciler runner | [`reconciler.py`](../../server/app/domains/data_access/reconciler.py), [`test_observe_reconciler.py`](../../server/tests/test_data_access/test_observe_reconciler.py), [`driver_base.py`](../../server/app/integrations/driver_base.py), [`bigquery_data_access.py`](../../server/app/integrations/drivers/bigquery_data_access.py), this document's [BigQuery Dataset ACL Patch Behavior](#bigquery-dataset-acl-patch-behavior) section | DAX-033 adds `apply_present_reconcile_compiled_effects(...)`. The runner locks selected pending effects, ignores absent effects, gates present effects through the DAX-032 apply-mode config, reads each allowed dataset IAM etag, and calls `set_dataset_iam` with add-only `DataAccessDatasetIamPolicyPatch` entries for exact unconditional Google Group read grants. It records `apply_present` effect-run rows, updates effect/binding/request reconcile status, and audits gate decisions plus provider outcomes. | Present/grant apply only. No scheduled job, request handler mutation path, Feishu callback mutation path, HTTP/MCP/UI surface, project IAM, `bigquery.jobUser`, broad scan, table access, query job, raw-provider policy persistence, or cloud-lifecycle grant path is introduced; DAX-034 owns absent/revoke apply. |
| Data-access apply-absent reconciler runner | [`reconciler.py`](../../server/app/domains/data_access/reconciler.py), [`test_observe_reconciler.py`](../../server/tests/test_data_access/test_observe_reconciler.py), [`driver_base.py`](../../server/app/integrations/driver_base.py), [`bigquery_data_access.py`](../../server/app/integrations/drivers/bigquery_data_access.py), this document's [BigQuery Dataset ACL Patch Behavior](#bigquery-dataset-acl-patch-behavior) section | DAX-034 adds `apply_absent_reconcile_compiled_effects(...)`. The runner locks selected pending/retryable effects, ignores non-absent effects and absent effects whose binding is not already revoked/revoked-pending, gates allowed absent effects through the DAX-032 apply-mode config, reads each dataset IAM etag, and calls `set_dataset_iam` with remove-only `DataAccessDatasetIamPolicyPatch` entries for exact unconditional Google Group read grants. It records `apply_absent` effect-run rows, marks successful effects applied, promotes `revoked_pending` bindings to `revoked`, refreshes request/binding roll-ups, and audits gate/provider outcomes. | Absent/revoke apply only. No scheduled job, request handler mutation path, Feishu callback mutation path, HTTP/MCP/UI surface, project IAM, `bigquery.jobUser`, broad scan, table access, query job, raw-provider policy persistence, or cloud-lifecycle grant path is introduced. Dataset scope is managed by `infra/gcp-dax`. |
| Data-access already-present adoption proof | [`reconciler.py`](../../server/app/domains/data_access/reconciler.py), [`service.py`](../../server/app/domains/data_access/service.py), [`test_observe_reconciler.py`](../../server/tests/test_data_access/test_observe_reconciler.py), this document's [BigQuery Dataset ACL Patch Behavior](#bigquery-dataset-acl-patch-behavior) section | DAX-035 exposes and audits `already_present_at_first_apply=true` when an approved compiled present effect is already present at the provider on first apply, then proves `revoke_data_access_binding(...)` compiles an absent effect for the same provider member/role/locator and the absent runner removes only that exact adopted grant. | Adoption is effect-scoped, not broad provider reconciliation. It does not adopt unmanaged grants from observe reports, does not scan broadly, does not create group membership authority, and adds no scheduled job, request/callback mutation path, HTTP/MCP/UI surface, project IAM, `bigquery.jobUser`, table access, query job, Terraform apply, or cloud-lifecycle grant path. |
| Data-access failure-semantics proof | [`test_observe_reconciler.py`](../../server/tests/test_data_access/test_observe_reconciler.py), this document's [BigQuery Dataset ACL Patch Behavior](#bigquery-dataset-acl-patch-behavior) section | DAX-036 proves provider auth/read failure leaves apply-present effects retryable/error without a provider write and redacts private-key material, and proves partial apply-absent provider failure affects only the failed locator while successful locators may finish their own revoke. | Failure-proof only. It adds no new production behavior, scheduled job, request/callback mutation path, HTTP/MCP/UI surface, project IAM, `bigquery.jobUser`, table access, query job, Terraform apply, or cloud-lifecycle grant path. |
| Data-access canary audit-trail proof | [`test_canary_audit_trail.py`](../../server/tests/test_data_access/test_canary_audit_trail.py), this document's [BigQuery Dataset ACL Patch Behavior](#bigquery-dataset-acl-patch-behavior) section | DAX-037 proves one full canary grant/revoke lifecycle leaves request, callback, binding, compile, apply, revoke, provider outcome, etag, effect-run, and trace-id evidence across audit logs and effect run history. | Audit-proof only. It adds no new production behavior, scheduled job, request/callback mutation path, HTTP/MCP/UI surface, project IAM, `bigquery.jobUser`, table access, query job, Terraform apply, or cloud-lifecycle grant path. |
| Data-access request UI | [`DataAccessRequestPage.tsx`](../../web/src/pages/dataAccess/DataAccessRequestPage.tsx), [`dataAccess.ts`](../../web/src/api/dataAccess.ts), [`App.tsx`](../../web/src/App.tsx), [`MainLayout.tsx`](../../web/src/layouts/MainLayout.tsx), [`test_router.py`](../../server/tests/test_data_access/test_router.py) | DAX-038 adds `/my/data-access/requests` for group owners and global admins. The UI exposes only the V1 request shape: one active Google Group principal, fixed `read`, many requestable BigQuery datasets, a business reason, and one submit action. The regression proves selecting 10 datasets creates one semantic request, 10 request items, one approval request, one Feishu approval payload, and zero pre-approval bindings/effects; it also proves non-owner employees cannot enumerate requestable dataset locators/owner labels. | This is request authoring only. It does not expose entitlement/provider-role editing, table/single-user/project IAM, dataset creation, manual catalog management, Feishu template authoring, Google Group membership reconciliation, callback handling, provider mutation, or admin reconciliation/run-history operations. |
| New data-access driver/UI/feed surfaces | Repository grep for data-access surfaces beyond constants/router/service/schemas/resource/entitlement/request/binding/effect models, validators/requester authorization, dry-run compiler, Feishu payload builder, request submission service, approval callback handler, callback staleness hardening, revoke service, audit-event coverage, BigQuery driver registration, locator-scoped dataset IAM driver interface, BigQuery IAM read path, observe-mode reconciler runner, observe reconciler locking/idempotency, effect run history/roll-ups, unmanaged grant reporting, negative IAM/broad-scan guard coverage, least-privilege credential contract, dataset ACL canary planner, guarded apply/revoke driver method, reconciler apply gate, apply-present reconciler runner, apply-absent reconciler runner, already-present adoption proof, failure-semantics proof, canary audit-trail proof, DAX-038 HTTP request facade, DAX-038 request UI, DAX-039 request detail UI, DAX-040 group-access UI, DAX-041 failed-apply admin UI/retry action, DAX-042 reconciler run-history admin UI, and DAX-045 through DAX-050 catalog feed projection/cutover handling | No scheduled jobs, MCP request endpoint, provider mutation outside the guarded driver/reconciler path, request/callback BigQuery mutation, manual catalog UI, or non-V1 request shape exist yet. | Follow-up issues must add these surfaces explicitly and update `PLATFORM.md` / `CHANGELOG.md` as they land. |

## Ownership Split

### Catalog Or Bootstrap Owns Dataset Identity

`data_access_resources` projects requestable datasets from catalog feed input.
Until catalog cutover is complete, an ops-only bootstrap input may seed rows in
the same shape and later be replaced in place by an exact catalog locator match.

The resource projection owns:

- stable resource identity
- provider locator: BigQuery `project_id` and `dataset_id`
- display label, business description, catalog labels, and owner context
- requestability state
- lifecycle status
- catalog source/version/etag when catalog-fed

Code-level catalog feed ingestion is defined by
[`server/app/domains/data_access/catalog_feed.py`](../../server/app/domains/data_access/catalog_feed.py):

- `provider='bigquery'`
- `resource_type='dataset'`
- structured `locator.project_id` + `locator.dataset_id`
- optional display label and business `description` (persisted as `notes`), plus `labels`
- `requestable` and `status`
- `source_version` and `source_etag`
- either `owner_person_id` or explicit `owner_resolution_status` of
  `unresolved_owner` / `fallback_owner`

Projection writes `owner_source='catalog'` into the resource row. It audits the
created/updated/unchanged/catalog-missing-suspended outcomes and never calls
BigQuery, creates approvals, or mutates bindings/effects.

Bootstrap input must be shaped like catalog input. Code-level bootstrap input is
defined by [`server/app/domains/data_access/bootstrap.py`](../../server/app/domains/data_access/bootstrap.py):

- `provider='bigquery'`
- `resource_type='dataset'`
- structured `locator.project_id` + `locator.dataset_id`
- `owner_source='bootstrap'`
- `source_version` and `source_etag`
- either `owner_person_id` or explicit `owner_resolution_status` of
  `unresolved_owner` / `fallback_owner`

It must not create a second identity system or a user-facing manual dataset
catalog.

### Resource Owner Authority

`data_access_resources.owner_person_id` is the only runtime resource-owner
authority used by data-access request/revoke authorization. It is projected from
catalog or ops-bootstrap input into the resource row; request handlers and
revoke services must not raw-read the external catalog or bootstrap files as a
second authorization source.

| Resource phase | Owner source | Runtime reader | Stale or mismatch behavior |
| --- | --- | --- | --- |
| Catalog-fed | Catalog projection writes `owner_person_id`, `owner_source`, `source_version`, `source_etag`, labels, requestability, and lifecycle status into `data_access_resources`. | Request validation, Feishu payload context, resource-owner revoke, stale callback checks, UI. | Owner/version/etag changes before callback make the pending request stale. Runtime authorization uses the current projected row, not a cached Feishu widget value or raw catalog feed. |
| Catalog missing | A later authoritative catalog feed omits a previously active `owner_source='catalog'` row. | Request validation, UI/admin notices, group-access projections, existing binding/revoke readers. | The row moves to `status='suspended'` with `catalog_missing_suspended` audit evidence. New authoring is blocked; existing bindings/effects remain unchanged and are not auto-revoked. |
| Confirmed deleted/removed | Catalog explicitly projects `status='deleted'`, or a trusted admin/system action confirms the dataset should be removed from DAX management. | Request validation, group-access/detail history, revoke service, reconciler apply-absent input. | The row moves to `status='deleted'` / `requestable=false`; active bindings become `revoked_pending` and absent effects are compiled from grant-time snapshots. Replays are idempotent and do not call BigQuery directly. |
| Ops-bootstrap | Bootstrap file/parser writes the same owner fields and marks `source='bootstrap'`. | Same as catalog-fed while bootstrap is still requestable. | Missing or unresolved owners disable the resource-owner authorization path; IT/global admin remains the fallback. Bootstrap rows with unresolved owner/fallback cases cannot pass catalog cutover. |
| Cutover exact match | Catalog row replaces bootstrap source/version/etag/owner context while preserving resource id, requests, bindings, effects, audit, and run history. | Same resource id and current projected owner fields. | Existing bindings continue; future owner decisions use the catalog-projected owner after the replacement is audited. |
| Cutover catalog-id locator drift | Catalog id matches a bootstrap- or catalog-owned row, but the provider locator differs and the row already has request/binding/effect/run history. | Admin cutover report only until resolved; group-access/history readers continue showing the preserved provider locator. | New authoring is suspended, the old provider locator and compiled-effect targets are preserved, and audit records `bootstrap_locator_drift_suspended` or `catalog_locator_drift_suspended` with previous/catalog locators. |
| Cutover owner mismatch | Bootstrap and catalog disagree about owner context. | Admin cutover report only until resolved. | New authoring is suspended for that resource. Existing bindings/effects remain unchanged; no resource-owner revoke authority is granted from either side until an admin resolves the mismatch. |

### Bootstrap-To-Catalog Cutover Cleanup

Bootstrap rows are transition input, not a second durable catalog. Catalog
cutover keeps the execution shape unchanged: request/revoke services still write
semantic state, and only the reconciler mutates provider IAM.

Cutover cleanup must run as an explicit report/apply sequence:

1. Inventory every active bootstrap resource and classify it as matched,
   unmatched, duplicate-locator, locator-drift-with-history, owner-mismatch,
   unresolved-owner/fallback, not-requestable, or invalid.
2. Match catalog rows to bootstrap rows by catalog id when present, otherwise by
   provider locator `(integration_id, project_id, dataset_id)`.
3. For exact matches, replace the resource's source/version/etag/owner context
   with the catalog projection while preserving request, binding, effect, audit,
   and run-history continuity.
4. For catalog-id locator drift on a row with preserved history, keep the old
   provider locator and compiled-effect targets unchanged, suspend new authoring,
   write an admin-visible cutover finding, and require explicit admin resolution
   before catalog cutover can be marked complete.
5. For owner mismatches, keep existing bindings and provider effects unchanged;
   suspend new authoring for that resource, write an admin-visible cutover
   finding, and require explicit admin resolution before catalog cutover can be
   marked complete.
6. For unmatched bootstrap rows, close new authoring by marking them
   non-requestable/suspended. Do not auto-revoke active bindings solely because a
   bootstrap row did not match catalog; catalog-missing and provider-confirmed
   deletion flows own later suspension or revocation.
7. After the report has zero unresolved duplicate-locator,
   locator-drift-with-history, owner-mismatch, unresolved-owner/fallback, and
   invalid rows, block creation of new
   requestable bootstrap rows. Historical bootstrap-sourced rows remain readable
   as provenance.

The cutover gate proves "new requestable datasets come only from catalog feed";
it does not mean "all historical bootstrap rows were deleted" and it does not
permit broad provider cleanup.

### Email Group Domain Owns Google Group Facts

The data-access domain validates Google Groups and group ownership through the
existing group directory/read model. It does not reconcile group membership and
does not create groups.

Principal identity is deliberately split:

| Layer | Contract |
| --- | --- |
| Durable semantic key | `data_access_bindings` and `data_access_requests` reference `email_groups.id`. This is the principal identity inside Helix. |
| Provider anchor | `email_groups.google_group_id` is stored as a snapshot when present and used to detect primary-email rename continuity. It is not the request input. |
| Provider member | `group:<primary_email_snapshot>` is persisted on the compiled effect. It is provider-specific compile output and changes only through recompilation. |
| Aliases | `email_groups.aliases` may help search/resolve a user-entered group address, but new durable authoring must resolve to exactly one active primary group row before request submission. Aliases are not provider members. |
| Rename bridge | If a referenced group row becomes `deleted` while another active row has the same `google_group_id`, treat it as a primary-email rename, not a deletion. Relink or bridge the binding to the active row, compile an absent effect for the old `group:<email>` snapshot, and compile a present effect for the new primary email. |
| Ambiguity | Duplicate active rows for the same `google_group_id`, alias collisions, or missing `google_group_id` on a rename candidate are fail-closed admin findings. They block new requests and callback binding creation for that group until resolved. |

Data-access request authorization relies on:

- the requester being a current owner row in `email_group_owners` for the resolved `email_groups.id`,
- IT/global admin authority after request-scoped view-role narrowing, or
- later system revocation authority for cleanup paths.

Primary email and aliases are resolution/search aids only; they are not
authorization keys.

Unknown, deleted, or non-group principals are rejected before Feishu submission.
Deleted groups with no active same-`google_group_id` bridge trigger system
revocation for active bindings; rename bridges do not.

### Approval Domain Owns Feishu Transport

The existing approval/Feishu integration owns approval instance creation,
signature verification, callback authenticity, and transport-level retries.

The data-access domain owns the semantic request state:

- requested group
- requested entitlement
- requested dataset items
- compiled dry-run preview
- callback transition decision
- staleness result

The Feishu template is configured outside Helix. Helix maps to the
`data_access_group_grant` flow but does not author the template. The
data-access resolver fails closed when the mapping is absent/inactive and
rejects external-origin instances for this flow. For V1, Feishu is the human
approval decision surface: Helix sends the semantic request packet, then Helix
validates callback authenticity, request binding, and staleness before creating
durable data-access state.

### Data-Access Domain Owns Desired State

The data-access domain owns durable semantic state:

- `data_access_entitlements`
- `data_access_requests`
- `data_access_request_items`
- `data_access_bindings`
- `data_access_compiled_effects`
- data-access audit events
- data-access run/apply visibility

Approved requests create bindings and compiled effects. They do not mutate
BigQuery.

### Reconciler Owns Provider Mutation

The reconciler is the only component that can call the BigQuery data-access
driver write path. It consumes compiled effects and moves provider state toward
the desired state.

DAX-021 shipped the dedicated `data_access/bigquery` registry entry. DAX-022
defines the locator-scoped interface below before any reconciler may read
provider state:

- `get_dataset_iam(project_id, dataset_id)`
- `set_dataset_iam(project_id, dataset_id, expected_etag, policy_patch)`

There must be no broad `list all datasets and manage` operation in the driver
or reconciler. There must be no project IAM or `bigquery.jobUser` path.
DAX-023 implements only the driver read side of this surface. DAX-024 consumes
that read side in observe mode to record bounded add/remove/unchanged/error
deltas from persisted effects only; write/apply remains closed until the
explicit apply issues.

### CMDB/GCP Cloud Driver Does Not Own This Path

The existing CMDB cloud lifecycle and GCP cloud driver remain separate. Data
access uses a dedicated `kind='data_access'`, `provider='bigquery'` integration
driver so dataset IAM effects do not route through generic cloud grants.

## Security Planning Bar

Because V1 grants production data visibility, no implementation PR may introduce
a new public/API surface, callback transition, credential path, or provider
mutation path until its row in this bar is satisfied by tests or an explicit
implementation note.

| Area | V1 requirement | Required proof |
| --- | --- | --- |
| Assets | Dataset locator, group or principal email snapshot, immutable personal subject UUID, group owner facts, business reason, Feishu instance id, compiled effect, provider IAM response, and service-account credentials are protected assets. | Schemas/tests avoid leaking credential material; audit and run history record ids/outcomes without storing provider secrets. |
| Actors | Requester, personal principal, Google Group owner, resource owner, IT/global admin, Feishu approver/callback sender, scheduled reconciler, and system cleanup paths are distinct actors. | Authorization tests cover self-personal allowed, on-behalf denied, group owner allowed, non-owner denied, IT/global admin allowed, resource owner revoke allowed, and system revocation Feishu-free. |
| Trust boundaries | Browser/API, Helix backend, Feishu callback, email-group read model, catalog/bootstrap feed, BigQuery driver, and scheduled worker are separate trust boundaries. | Callback tests verify signature/authenticity, request binding, idempotency, stale refusal, and no direct BigQuery call in request/callback code. |
| Authentication | HTTP paths use the existing Helix auth/RBAC dependencies; Feishu callbacks use approval-domain authenticity checks; worker runs under system authority only for reconciler/system cleanup. | Tests assert employee/non-owner denial and callback rejection when approval authenticity or request binding fails. |
| Authorization | A personal `read` request may be made only by its active authenticated employee; Group `read` still requires current group owner/member or IT/global admin. Revoke may be done by the matching personal principal, authorized group owner, resource owner, IT/global admin, or system cleanup. Resource-owner authority comes only from `data_access_resources.owner_person_id`; Google Group authority comes only from `email_group_owners` for the resolved `email_groups.id`; personal authority comes only from the live `identity_persons` UUID/work-email projection. | Tests pin self/on-behalf, active/inactive, email-change, group owner/member, owner-mismatch, and group rename-vs-delete cases. View-role narrowing must be honored by HTTP paths. |
| Credential lifecycle | BigQuery credentials belong to the dedicated data-access integration. Feishu credentials remain approval-domain config. Neither request payloads nor compiled effects store raw secrets. | DAX-023 driver tests prove credential loading is scoped to the dedicated `data_access/bigquery` driver and uses only `service_account_json`; integration serialization tests prove secret fields are redacted. |
| Abuse cases | Rejected cases include a caller-supplied personal email/person id, on-behalf personal request, table/project resource, raw provider role request, `bigquery.jobUser`, broad dataset scan, forged/stale callback, deleted group, and unauthorized requester. | Negative tests fail closed for each case before Feishu submission or before binding creation. |
| Provider mutation | Only the reconciler may call `set_dataset_iam`; request handlers, Feishu payload builders, and callbacks must never mutate BigQuery. | Spy/mock tests assert no BigQuery write method is reachable outside reconciler apply mode. |
| Observability | Every request/callback/binding/compile/reconcile/revoke/provider outcome has audit/run-history traceability with a trace id. | DAX-020 tests assert audit trace chains for submission, callbacks, binding creation, compile output, revoke intent, system cleanup, rename bridges, and failure states; future reconciler/provider run history must extend the same trace model. |
| Rollout gates | Apply mode requires observe-mode proof, an explicit apply switch, Terraform-managed dataset IAM from the repo seed, and provider failure handling. | M2/M3 gates below pass before any mutation is enabled. |

## Request Lifecycle

1. Requester selects personal, one Google Group, or one typed Service Account,
   then `read` and one or more requestable BigQuery dataset resources. The
   personal selection has no target-input field and is derived from login.
2. Service validates every dataset resource before Feishu submission.
3. Service validates the Google Group principal before Feishu submission.
4. Service validates requester authority before Feishu submission.
5. Dry-run compiler returns the exact provider effects that approval would
   create.
6. Feishu payload presents the semantic request and compiled preview.
7. Submission service persists the pending request/request-items, creates the
   Feishu instance, and links the `approval_requests` row.
8. On approved callback, the callback handler locks the request and revalidates
   staleness gates.
9. Fresh approval creates one binding and one compiled effect per approved item.
10. Reconciler observe mode reports intended deltas without mutation.
11. After observe-mode and apply gates pass, reconciler apply mode mutates only
    exact dataset IAM member/role effects allowed by the DAX service account.

Duplicate submission/callback/reconcile events must be idempotent. Every
state-changing transition must be auditable with a trace id.

## Binding And Effect Semantics

A binding is semantic:

```text
resource dataset + principal google_group + entitlement read
```

A compiled effect is provider-specific:

```text
target: bigquery dataset {project_id, dataset_id}
member: group:<group_email>
role: roles/bigquery.dataViewer
desired_state: present | absent
```

One active binding per `(resource, principal, entitlement)` is allowed.
Historical revoked bindings remain queryable.

Compiled effects must persist provenance:

- entitlement version
- catalog/resource version
- compiler version
- target locator
- managed reason
- first-apply adoption marker for already-present grants
- status and last provider outcome

## Staleness Gates

Approved callbacks create no bindings when the pending request is stale. The
request should instead move to a visible `stale_requires_resubmit` state.

Required stale checks:

- entitlement version changed
- resource catalog version or etag changed
- resource owner context changed while the request is pending
- compiler version changed in a way that changes output
- Google Group was deleted or is no longer a valid principal, unless a
  same-`google_group_id` primary-email rename bridge is resolved first
- Google Group primary email changed and the compiled preview no longer matches
  the provider member that would be created
- requester is no longer authorized when the callback is processed
- resource is no longer requestable
- catalog reports missing/suspended resource after catalog cutover

DAX-046 pins the catalog version/etag branch as an observable runtime contract:
`source_version` or `source_etag` drift between request submission and approval
callback returns `resource_catalog_version_stale`, marks the pending request
`stale_requires_resubmit`, marks items stale with that same reason, audits the
changed field/current/snapshot values, and creates no bindings or compiled
effects. This is still a projected-row comparison against
`data_access_resources`; the callback does not read raw catalog feed payloads or
call BigQuery.

## Revocation Semantics

Revocation does not require Feishu approval. Authorized group owners, resource
owners, IT/global admins, and system cleanup paths may create revoke intent.

Google Group deletion is a system revocation trigger, not only a pending-request
staleness condition. When the email-group read model reports that a Google Group
is deleted or no longer a valid principal, data access must create revoke intent
for every active binding owned by that principal, compile absent effects, and let
the reconciler remove the exact managed provider grants. The cleanup path must be
idempotent, audit-visible, and Feishu-free.

DAX-019 owns this trigger end to end: detect the deleted/invalid group signal
from the email-group read model, mark affected active bindings revoked-pending,
compile absent effects, and emit audit-visible system revocation state. DAX-043
only renders the resulting notice; it must not be the first place this state is
created.

DAX-048 owns resource-side confirmed removal. Catalog-missing suspension is not
a revoke trigger; only explicit catalog `status='deleted'` or trusted
admin/system confirmation may move a resource to deleted, mark its active
bindings `revoked_pending`, and compile absent effects. The service path records
`resource_provider_confirmed_deleted` or `resource_admin_confirmed_removed`
reasons, emits `data_access.resource.removal_confirmed`, and sets
`provider_mutation_attempted=false`.

Revocation changes semantic desired state and compiles an absent provider
effect. The reconciler removes only the exact Helix-managed member/role effect:

```text
group:<email> + roles/bigquery.dataViewer + dataset locator
```

It must leave unrelated members, roles, project IAM, dataset ACL entries, and
non-Helix provider grants untouched.

## Reconciler Gates

Provider mutation is gated by milestone:

- M2 observe mode records intended add/remove deltas and never calls
  `set_dataset_iam`.
- M3 apply mode is restricted by an explicit apply switch plus
  Terraform-managed dataset IAM from the repo seed.
- Apply uses etag optimistic concurrency and does not mark an effect active
  after a stale-etag conflict.
- Provider auth or transient failures leave effects retryable/failed, not
  silently active.
- No partial provider failure can trigger broad revocation.

## Operational Gate Definitions

These words are binding for follow-up issues:

| Gate phrase | Must mean |
| --- | --- |
| Canonical names fixed | Constants exist for every value in the canonical-name table, open-coded aliases are not accepted at API/schema/compiler boundaries, and tests reject `user`, `table`, raw provider roles, `gcp`, and `cmdb_data_access`. |
| Principal identity fixed | `email_groups.id` is the semantic principal key; `google_group_id` is a continuity/rename anchor; `group:<primary_email_snapshot>` is compiled provider output; aliases are resolution aids only. Tests cover primary-email rename, alias collision, duplicate `google_group_id`, deleted group with no bridge, and missing `google_group_id` ambiguity. |
| Resource owner authority fixed | `data_access_resources.owner_person_id` is the only runtime resource-owner authorization source. Tests cover catalog-fed owner changes, bootstrap unresolved owner fallback, cutover exact owner replacement, cutover locator-drift suspension, and cutover owner mismatch suspension. |
| Bootstrap input fixed | Ops-bootstrap entries use the catalog-shaped parser: BigQuery dataset locator only, projected owner/source fields, optional catalog id, and explicit unresolved/fallback owner states that disable resource-owner authorization. Parser tests accept valid BigQuery dataset ids, including numeric-leading ids, and reject table/project/freeform entries plus duplicate locators/catalog ids. |
| Feishu-backed request safe to create | Resource validation, Google Group validation, requester authorization, dry-run compile, active Feishu flow config lookup, no external-origin flow acceptance, and security-bar negative tests all run before Feishu submission. |
| No BigQuery mutation in M1 | Request submission, payload build, and callback tests spy on the BigQuery driver/reconciler write surface and observe zero calls. Bindings/effects may be created; provider IAM cannot change. |
| Bounded deltas proven | Observe reconcile records add/remove/unchanged/unmanaged/error counts only for explicitly selected compiled effects/resource ids and their dataset locators. No broad dataset enumeration, project IAM, or `jobUser` method is called. |
| BigQuery IAM behavior verified | DAX-029/DAX-030 document the exact Google permissions/OAuth scope and prove that dataset IAM get/set updates the intended member/role without damaging unrelated dataset ACL/IAM entries. |
| Apply/revoke exact | Apply mode is on, the DAX service account has Terraform-managed dataset IAM from the repo seed, and provider policy diff after apply/revoke changes only `group:<email>` + `roles/bigquery.dataViewer` for the target dataset locator. |
| Bootstrap creation closed | New requestable bootstrap resource creation is rejected after cutover; existing bootstrap-sourced rows stay readable as provenance. |
| Cutover complete | Cleanup report has zero unresolved duplicate-locator, duplicate-catalog-id, locator-drift-with-history, owner-mismatch, unresolved-owner/fallback, or invalid rows; unmatched bootstrap rows are non-requestable/suspended; no provider grant was revoked solely because a bootstrap row failed to match catalog. |

## Audit And Run History

The audit trail must explain:

- request submission
- Feishu instance creation
- callback receipt and decision
- stale callback refusal
- binding creation
- compile output
- revoke intent
- reconcile observe delta
- provider apply/revoke outcome
- retry and failure states

DAX-026 starts that run-history surface with `data_access_effect_runs` for every
observe-processed compiled effect. The row snapshots the resource, principal,
effect, provider outcome, retryable/error state, and trace id so later admin
surfaces can answer which request or revocation produced a provider delta.

## Verification Baseline

For DAX-001 and every follow-up issue:

- `git diff --check` must pass.
- Docs and PR descriptions must name the V1 scope, non-goals, runtime path, and
  provider-mutation gate.
- Request handlers and Feishu callbacks must not call BigQuery directly.
- Security-bar and operational-gate rows touched by the issue must be satisfied
  by tests or a named implementation note.
- Tests must pin the narrow behavior introduced by the issue.

Implementation work starts after this contract and the ADR are present.
