Files
lmxopcua/docs/v2/acl-design.md
Joseph Doherty 4903a19ec9 Add data-path ACL design (acl-design.md, closes corrections B1) + dev-environment inventory and setup plan (dev-environment.md), and remove consumer cutover from OtOpcUa v2 scope.
ACL design defines NodePermissions bitmask flags covering Browse / Read / Subscribe / HistoryRead / WriteOperate / WriteTune / WriteConfigure / AlarmRead / AlarmAcknowledge / AlarmConfirm / AlarmShelve / MethodCall plus common bundles (ReadOnly / Operator / Engineer / Admin); 6-level scope hierarchy (Cluster / Namespace / UnsArea / UnsLine / Equipment / Tag) with default-deny + additive grants and Browse-implication on ancestors; per-LDAP-group grants in a new generation-versioned NodeAcl table edited via the same draft → diff → publish → rollback boundary as every other content table; per-session permission-trie evaluator with O(depth × group-count) cost cached for the lifetime of the session and rebuilt on generation-apply or LDAP group cache expiry; cluster-create workflow seeds a default ACL set matching the v1 LmxOpcUa LDAP-role-to-permission map for v1 → v2 consumer migration parity; Admin UI ACL tab with two views (by LDAP group, by scope), bulk-grant flow, and permission simulator that lets operators preview "as user X" effective permissions across the cluster's UNS tree before publishing; explicit Deny deferred to v2.1 since verbose grants suffice at v2.0 fleet sizes; only denied OPC UA operations are audit-logged (not allowed ones — would dwarf the audit log). Schema doc gains the NodeAcl table with cross-cluster invariant enforcement and same-generation FK validation; admin-ui.md gains the ACLs tab; phase-1 doc gains Task E.9 wiring this through Stream E plus a NodeAcl entry in Task B.1's DbContext list.

Dev-environment doc inventories every external resource the v2 build needs across two tiers per decision #99 — inner-loop (in-process simulators on developer machines: SQL Server local or container, GLAuth at C:\publish\glauth\, local dev Galaxy) and integration (one dedicated Windows host with Docker Desktop on WSL2 backend so TwinCAT XAR VM can run in Hyper-V alongside containerized oitc/modbus-server, plus WSL2-hosted Snap7 and ab_server, plus OPC Foundation reference server, plus FOCAS TestStub and FaultShim) — with concrete container images, ports, default dev credentials (clearly marked dev-only since production uses Integrated Security / gMSA per decision #46), bootstrap order for both tiers, network topology diagram, test data seed locations, and operational risks (TwinCAT trial expiry automation, Docker pricing, integration host SPOF mitigation, per-developer GLAuth config sync, Aveva license scoping that keeps Galaxy tests on developer machines and off the shared host).

Removes consumer cutover (ScadaBridge / Ignition / System Platform IO) from OtOpcUa v2 scope per decision #136 — owned by a separate integration / operations team, tracked in 3-year-plan handoff §"Rollout Posture" and corrections §C5; OtOpcUa team's scope ends at Phase 5. Updates implementation/overview.md phase index to drop the "6+" row and add an explicit "OUT of v2 scope" callout; updates phase-1 and phase-2 docs to reframe cutover as integration-team-owned rather than future-phase numbered.

Decisions #129–137 added: ACL model (#129), NodeAcl generation-versioned (#130), v1-compatibility seed (#131), denied-only audit logging (#132), two-tier dev environment (#133), Docker WSL2 backend for TwinCAT VM coexistence (#134), TwinCAT VM centrally managed / Galaxy on dev machines only (#135), cutover out of v2 scope (#136), dev credentials documented openly (#137).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:58:33 -04:00

22 KiB
Raw Permalink Blame History

OPC UA Client Authorization (ACL Design) — OtOpcUa v2

Status: DRAFT — closes corrections-doc finding B1 (namespace / equipment-subtree ACLs not yet modeled in the data path).

Branch: v2 Created: 2026-04-17

Scope

This document defines the OPC UA client data-path authorization model — who can read, write, subscribe, browse, ack alarms, etc. on which nodes when connecting to the OtOpcUa server endpoint. It is distinct from:

  • Admin UI authorization (admin-ui.md) — who can edit configuration. That layer has FleetAdmin / ConfigEditor / ReadOnly roles + cluster-scoped grants per decisions #88, #105.
  • DB principal authorization (config-db-schema.md §"Authorization Model") — who can call which stored procedures on the central config DB. That layer is per-NodeId for cluster nodes and per-Admin for Admin app users.

The data-path ACL layer covers OPC UA clients (ScadaBridge, Ignition, System Platform IO, third-party tools) that connect to the OPC UA endpoint to read or modify equipment data.

Permission Model

Every node operation requires an explicit permission. Permissions are bitmask flags on the v2 schema; the OPC UA NodeManager checks them on every browse, read, write, subscribe, history read, alarm event, and method call.

Permission flags

[Flags]
public enum NodePermissions : uint
{
    None              = 0,

    // Read-side
    Browse            = 1 << 0,   // See node in BrowseRequest results
    Read              = 1 << 1,   // ReadRequest current value
    Subscribe         = 1 << 2,   // CreateMonitoredItems
    HistoryRead       = 1 << 3,   // HistoryReadRaw / HistoryReadProcessed

    // Write-side (mirrors v1 SecurityClassification model — see config-db-schema.md Equipment ACL)
    WriteOperate      = 1 << 4,   // Write attrs with FreeAccess/Operate classification
    WriteTune         = 1 << 5,   // Write attrs with Tune classification
    WriteConfigure    = 1 << 6,   // Write attrs with Configure classification

    // Alarm-side
    AlarmRead         = 1 << 7,   // Receive alarm events for this node
    AlarmAcknowledge  = 1 << 8,   // Ack alarms (separate from Confirm — OPC UA Part 9 distinction)
    AlarmConfirm      = 1 << 9,   // Confirm alarms
    AlarmShelve       = 1 << 10,  // Shelve / unshelve alarms

    // Method invocation (OPC UA Part 4 §5.11)
    MethodCall        = 1 << 11,  // Invoke methods on the node

    // Common bundles (also exposed in Admin UI as one-click selections)
    ReadOnly  = Browse | Read | Subscribe | HistoryRead | AlarmRead,
    Operator  = ReadOnly | WriteOperate | AlarmAcknowledge | AlarmConfirm,
    Engineer  = Operator | WriteTune | AlarmShelve,
    Admin     = Engineer | WriteConfigure | MethodCall,
}

The bundles (ReadOnly / Operator / Engineer / Admin) are derived from production patterns at sites running v1 LmxOpcUa — they're the common grant shapes operators reach for. Granular per-flag grants stay supported for unusual cases.

Why three Write tiers (Operate / Tune / Configure)

Mirrors v1's SecurityClassification mapping (docs/DataTypeMapping.md). Galaxy attributes carry a security classification; v1 maps FreeAccess/Operate to writable, SecuredWrite/VerifiedWrite/ViewOnly to read-only. The v2 model preserves this for Galaxy and extends it to all drivers via Tag.SecurityClassification:

Classification Permission needed to write
FreeAccess WriteOperate
Operate WriteOperate
Tune WriteTune
Configure WriteConfigure
SecuredWrite / VerifiedWrite / ViewOnly (not writable from OPC UA — v1 behavior preserved)

A user with WriteTune can write Operate-classified attrs too (Tune is more privileged). The check is requestedClassification ≤ grantedTier.

Why AlarmRead is separate from Read

In OPC UA Part 9 alarm subscriptions are a distinct subscription type — a client can subscribe to events on a node without reading its value. Granting Read alone does not let a client see alarm events; AlarmRead is required separately. The ReadOnly bundle includes both.

Why MethodCall is separate

OPC UA methods (Part 4 §5.11) are arbitrary procedure invocations on a node. v1 LmxOpcUa exposes very few; future drivers (especially OPC UA Client gateway) will surface more. MethodCall is gated explicitly because side-effects can be unbounded — analogous to executing a stored procedure rather than reading a column.

Scope Hierarchy

ACL grants attach to one of six scope levels. Granting at higher level cascades to lower (with browse implication for ancestors); explicit Deny at lower level is deferred to v2.1 (decision below).

Cluster                ← cluster-wide grant (highest scope)
  └── Namespace        ← per-namespace grant (Equipment vs SystemPlatform vs Simulated)
        └── UnsArea    ← per-area grant (Equipment-namespace only)
              └── UnsLine    ← per-line grant
                    └── Equipment    ← per-equipment grant
                          └── Tag    ← per-tag grant (lowest scope; rarely used)

For SystemPlatform-namespace tags (no Equipment row, no UNS structure), the chain shortens to:

Cluster
  └── Namespace
        └── (Tag's FolderPath segments — treated as opaque hierarchy)
              └── Tag

Inheritance and evaluation

For each operation on a node:

  1. Walk the node's scope chain from leaf to root (Tag → Equipment → UnsLine → UnsArea → Namespace → Cluster)
  2. At each level, look up NodeAcl rows where LdapGroup ∈ user.Groups and (ScopeKind, ScopeId) matches
  3. Union the PermissionFlags from every matching row
  4. Required permission must be set in the union → allow; else → deny
  5. Browse is implied at every ancestor of any node where the user has any non-Browse permission — otherwise the user can't navigate to it

Default-deny

If the union is empty (no group of the user's has any grant matching the node's chain), the operation is denied:

  • Browse → node hidden from results
  • Read / Subscribe / HistoryRead → BadUserAccessDenied
  • Write → BadUserAccessDenied
  • AlarmAck / AlarmConfirm / AlarmShelve → BadUserAccessDenied
  • MethodCall → BadUserAccessDenied

Why no explicit Deny in v2.0

Two patterns can express "X group can write everywhere except production line 3":

  • (a) Verbose: grant Engineering on every line except line 3 — many rows but unambiguous
  • (b) Explicit Deny that overrides Grant — fewer rows but evaluation logic must distinguish "no grant" from "explicit deny"

For v2.0 fleets (≤50 clusters, ≤20 lines per cluster typical) approach (a) is workable — operators use the bulk-grant Admin UI flow to apply grants across many lines minus exceptions. Explicit Deny adds non-trivial complexity to the evaluator and the Admin UI; defer to v2.1 unless a deployment demonstrates a real need.

Schema — NodeAcl Table

Generation-versioned (decision #105 pattern — ACLs are content, travel through draft → diff → publish like every other consumer-visible config):

CREATE TABLE dbo.NodeAcl (
    NodeAclRowId        uniqueidentifier    NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
    GenerationId        bigint              NOT NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
    NodeAclId           nvarchar(64)        NOT NULL,           -- stable logical ID across generations
    ClusterId           nvarchar(64)        NOT NULL FOREIGN KEY REFERENCES dbo.ServerCluster(ClusterId),
    LdapGroup           nvarchar(256)       NOT NULL,           -- LDAP group name (e.g. "OtOpcUaOperators-LINE3")
    ScopeKind           nvarchar(16)        NOT NULL CHECK (ScopeKind IN ('Cluster', 'Namespace', 'UnsArea', 'UnsLine', 'Equipment', 'Tag')),
    ScopeId             nvarchar(64)        NULL,               -- NULL when ScopeKind='Cluster'; otherwise the logical ID of the scoped entity
    PermissionFlags     int                 NOT NULL,           -- bitmask of NodePermissions
    Notes               nvarchar(512)       NULL
);

CREATE INDEX IX_NodeAcl_Generation_Cluster
    ON dbo.NodeAcl (GenerationId, ClusterId);
CREATE INDEX IX_NodeAcl_Generation_Group
    ON dbo.NodeAcl (GenerationId, LdapGroup);
CREATE INDEX IX_NodeAcl_Generation_Scope
    ON dbo.NodeAcl (GenerationId, ScopeKind, ScopeId) WHERE ScopeId IS NOT NULL;
CREATE UNIQUE INDEX UX_NodeAcl_Generation_LogicalId
    ON dbo.NodeAcl (GenerationId, NodeAclId);
-- Within a generation, a (Group, Scope) pair has at most one row (additive grants would be confusing
-- in the audit trail; use a single row with the union of intended permissions instead)
CREATE UNIQUE INDEX UX_NodeAcl_Generation_GroupScope
    ON dbo.NodeAcl (GenerationId, ClusterId, LdapGroup, ScopeKind, ScopeId);

Cross-generation invariant

Same pattern as Equipment / Namespace: NodeAclId is append-only per cluster — once published, the logical ID stays bound to its (LdapGroup, ScopeKind, ScopeId) triple. Renaming an LDAP group is forbidden — disable the old grant and create a new one. This protects the audit trail.

Validation in sp_ValidateDraft

Adds these checks beyond the existing schema rules:

  • ScopeId resolution: when ScopeKind ∈ {Namespace, UnsArea, UnsLine, Equipment, Tag}, ScopeId must resolve to the corresponding entity in the same generation
  • Cluster cohesion: the resolved scope must belong to the same ClusterId as the ACL row
  • PermissionFlags validity: bitmask must only contain bits defined in NodePermissions enum (no future-bit speculation)
  • LdapGroup format: non-empty, ≤256 chars, no characters that would break LDAP DN escaping (allowlist)
  • No identity drift: NodeAclId once published with (LdapGroup, ScopeKind, ScopeId) cannot have any of those four columns change in a future generation

Evaluation Algorithm

At session establishment

on AcceptSession(user):
    user.Groups = LdapAuth.ResolveGroups(user.Token)
    user.PermissionMap = BuildEffectivePermissionMap(currentGeneration, user.Groups)
    cache user.PermissionMap on the session

BuildEffectivePermissionMap produces a sparse trie keyed by node-path-prefix:

PermissionMap structure (per session):
  /                                                        → grant union from Cluster + Namespace-level rows
  /Equipment-NS/UnsArea-A/                                 → adds UnsArea-level grants
  /Equipment-NS/UnsArea-A/UnsLine-1/                       → adds UnsLine-level grants
  /Equipment-NS/UnsArea-A/UnsLine-1/Equipment-X/           → adds Equipment-level grants
  /Equipment-NS/UnsArea-A/UnsLine-1/Equipment-X/Tag-Y      → adds Tag-level grants (rare)

Lookup for a node at path P: walk the trie from / to P, OR-ing PermissionFlags at each visited level. Result = effective permissions for P. O(depth) — typically 6 or fewer hops.

Per-operation check

bool Authorize(SessionContext ctx, NodePath path, NodePermissions required)
{
    var effective = ctx.PermissionMap.Lookup(path);
    return (effective & required) == required;
}
  • Browse: Authorize(ctx, path, Browse) — falsy → omit from results
  • Read: Authorize(ctx, path, Read) → falsy → BadUserAccessDenied
  • Write: Authorize(ctx, path, requiredWriteFlag) where requiredWriteFlag is derived from the target attribute's SecurityClassification
  • Subscribe: Authorize(ctx, path, Subscribe) — also implies Browse on the path
  • HistoryRead: Authorize(ctx, path, HistoryRead)
  • Alarm event: Authorize(ctx, path, AlarmRead) — events for unauthorized nodes are filtered out before delivery
  • AlarmAck/Confirm/Shelve: corresponding flag check
  • MethodCall: Authorize(ctx, methodNode.path, MethodCall)

Cache invalidation

The session's PermissionMap is rebuilt when:

  • A new config generation is applied locally (the path-trie may have changed structure due to UNS reorg or new equipment)
  • The LDAP group cache for the user expires (default: 15 min — driven by the LDAP layer, separate from this design)
  • The user's session is re-established

For unattended consumer connections (ScadaBridge, Ignition) that hold long sessions, the per-generation rebuild keeps permissions current without forcing reconnects.

Performance

Worst-case per-operation cost: O(depth × group-count). For typical fleet sizes (10 LDAP groups per user, 6-deep UNS path), that's ~60 trie lookups per operation — sub-microsecond on modern hardware. The session-scoped cache means the per-operation hot path is array indexing, not DB queries.

Build cost (at session establish or generation reapply): O(N_acl × M_groups) for N_acl rows and M_groups in user's claim set. For 1000 ACL rows × 10 groups = 10k joins; sub-second on a sane DB.

Memory cost: per-session trie ~4 KB for typical scopes; bounded by O(N_acl) worst case. Sessions hold their own trie — no shared state to invalidate.

Default Permissions for Existing v1 LDAP Groups

To preserve v1 LmxOpcUa behavior on first migration, the v2 default ACL set on cluster creation maps the existing v1 LDAP-role-to-permission grants:

v1 LDAP role (per Security.md) v2 NodePermissions bundle Scope
ReadOnly (group: OtOpcUaReadOnly) ReadOnly bundle Cluster
WriteOperate (group: OtOpcUaWriteOperate) Operator bundle Cluster
WriteTune (group: OtOpcUaWriteTune) Engineer bundle Cluster
WriteConfigure (group: OtOpcUaWriteConfigure) Admin bundle Cluster
AlarmAck (group: OtOpcUaAlarmAck) adds AlarmAcknowledge | AlarmConfirm to user's existing grants Cluster

These are seeded by the cluster-create workflow into the initial draft generation (per decision #123 — namespaces and ACLs both travel through publish boundary). Operators can then refine to per-Equipment scopes as needed.

Admin UI

New tab: ACLs (under Cluster Detail)

/clusters/{ClusterId}            Cluster detail (tabs: Overview / Namespaces / UNS Structure / Drivers / Devices / Equipment / Tags / **ACLs** / Generations / Audit)

Two views, toggle at the top:

View 1 — By LDAP group

LDAP Group Scopes Permissions Notes
OtOpcUaOperators Cluster Operator bundle Default operators (seeded)
OtOpcUaOperators-LINE3 UnsArea bldg-3 Engineer bundle Line 3 supervisors
OtOpcUaScadaBridge Cluster ReadOnly Tier 1 consumer (added before cutover)

Click a row → edit grant: change scope, change permission set (one-click bundles or per-flag), edit notes.

View 2 — By scope (UNS tree)

Tree view of UnsArea → UnsLine → Equipment with permission badges per node showing which groups have what:

bldg-3/                                  [Operators: Operator, ScadaBridge: ReadOnly]
  ├── line-2/                            [+ LINE3-Supervisors: Engineer]
  │     ├── cnc-mill-05                  [+ CNC-Maintenance: WriteTune]
  │     ├── cnc-mill-06
  │     └── injection-molder-02
  └── line-3/

Click a node → see effective permissions per group, edit grants at that scope.

Bulk grant flow

"Bulk grant" button on either view:

  1. Pick LDAP group(s)
  2. Pick permission bundle or per-flag
  3. Pick scope set: pattern (e.g. all UnsArea matching bldg-*), or multi-select from tree
  4. Preview: list of NodeAcl rows that will be created
  5. Confirm → adds rows to current draft

Permission simulator

"Simulate as user" panel: enter username + LDAP groups → UI shows the effective permission map across the cluster's UNS tree. Useful before publishing — operators verify "after this change, ScadaBridge can still read everything it needs" without actually deploying.

Operator workflows added to admin-ui.md

Three new workflows:

  1. Grant ACL — usual draft → diff → publish, scoped to ACLs tab
  2. Bulk grant — multi-select scope + group + permission, preview, publish
  3. Simulate as user — preview-only, no publish required

v1 deviation log

For each cluster, the Admin UI shows a banner if its NodeAcl set diverges from the v1-default seed (per the table above). This makes intentional tightening or loosening visible at a glance — important for compliance review during the long v1 → v2 coexistence period.

Audit

Every NodeAcl change is in ConfigAuditLog automatically (per the publish boundary — same as any other content edit). Plus the OPC UA NodeManager logs every denied operation:

EventType = 'OpcUaAccessDenied'
DetailsJson = { user, groups, requestedOperation, nodePath, requiredPermission, effectivePermissions }

Allowed operations are NOT logged at this layer (would dwarf the audit log; OPC UA SDK has its own session/operation diagnostics for high-frequency telemetry). The choice to log denials only mirrors typical authorization-audit practice and can be tightened per-deployment if a customer requires full positive-action logging.

Test Strategy

Unit tests for the evaluator:

  • Empty ACL set → all operations denied (default-deny invariant)
  • Single Cluster-scope grant → operation allowed at every node in the cluster
  • Single Equipment-scope grant → allowed at the equipment + its tags; denied at sibling equipment
  • Multiple grants for same group → union (additive)
  • Multiple groups for same user → union of all groups' grants
  • Browse implication: granting Read on a deep equipment auto-allows Browse at every ancestor
  • Permission bundle expansion: granting Operator bundle = granting Browse | Read | Subscribe | HistoryRead | AlarmRead | WriteOperate | AlarmAcknowledge | AlarmConfirm
  • v1-compatibility seed: a fresh cluster with the default ACL set behaves identically to v1 LmxOpcUa for users in the v1 LDAP groups

Integration test (Phase 1+):

  • Create cluster + equipment + tags + ACL grants
  • Connect OPC UA client as a ReadOnly-mapped user → browse and read succeed; write fails
  • Re-publish with a tighter ACL → existing session's permission map rebuilds; subsequent writes that were allowed are now denied
  • Verify OpcUaAccessDenied audit log entries for the denied operations

Adversarial review checks (run during exit gate):

  • Can a client connect with no LDAP group at all and read anything? (must be no — default deny)
  • Can a client see a node in browse but not read its value? (yes, if Browse granted but not Read — unusual but valid)
  • Does a UnsArea rename cascade ACL grants correctly? (the grant references UnsAreaId not name, so rename is transparent)
  • Does an Equipment merge (Admin operator flow) preserve ACL grants on the surviving equipment? (must yes; merge flow updates references)
  • Does generation rollback restore the prior ACL state? (must yes; ACLs are generation-versioned)

Implementation Plan

ACL design enters the implementation pipeline as follows:

Phase 1 (Configuration + Admin scaffold)

  • Schema: add NodeAcl table to the Phase 1 migration
  • Validation: add NodeAcl rules to sp_ValidateDraft
  • Admin UI: scaffold the ACLs tab with view + edit + bulk grant + simulator
  • Default seed: cluster-create workflow seeds the v1-compatibility ACL set
  • Generation diff: include NodeAcl in sp_ComputeGenerationDiff

Phase 2+ (every driver phase)

  • Wire the ACL evaluator into GenericDriverNodeManager so every Browse / Read / Write / Subscribe / HistoryRead / AlarmRead / AlarmAck / MethodCall consults the per-session permission map
  • Per-driver tests: assert that a default-deny user cannot read or subscribe to that driver's namespace; assert that a ReadOnly-bundled user can; assert that the appropriate Write tier is needed for each SecurityClassification

Pre-tier-1-cutover (before Phase 6 / consumer cutover)

  • Verify ScadaBridge's effective permissions in the Admin UI simulator before any cutover
  • Adversarial review of the per-cluster ACL set with a fresh pair of eyes

Decisions to Add to plan.md

(Will be appended to the decision log on the next plan.md edit.)

# Decision Rationale
129 OPC UA client data-path authorization model = bitmask NodePermissions flags + per-LDAP-group grants on a 6-level scope hierarchy (Cluster / Namespace / UnsArea / UnsLine / Equipment / Tag) Closes corrections-doc finding B1. Mirrors v1 SecurityClassification model for Write tiers; adds explicit AlarmRead/Ack/Confirm/Shelve and MethodCall flags. Default-deny; additive grants; explicit Deny deferred to v2.1. See acl-design.md
130 NodeAcl table generation-versioned, edited via draft → diff → publish like every other content table Same pattern as Namespace (decision #123) and Equipment (decision #109). ACL changes are content, not topology — they affect what consumers see at the OPC UA endpoint. Rollback restores the prior ACL state
131 Cluster-create workflow seeds default ACL set matching v1 LmxOpcUa LDAP-role-to-permission map Preserves behavioral parity for v1 → v2 consumer migration. Operators tighten or loosen from there. Admin UI flags any cluster whose ACL set diverges from the seed
132 OPC UA NodeManager logs denied operations only; allowed operations rely on SDK session/operation diagnostics Logging every allowed op would dwarf the audit log. Denied-only mirrors typical authorization audit practice. Per-deployment policy can tighten if compliance requires positive-action logging

Open Questions

  • OPC UA Method support scope: how many methods does v1 expose? Need to enumerate before tier 3 cutover (System Platform IO is the most likely consumer of methods). The MethodCall permission is defined defensively but may not be exercised in v2.0.
  • Group claim source latency: LDAP group cache TTL (default 15 min above) is taken from the v1 LDAP layer. If the OPC UA session's group claims need to be refreshed faster (e.g. for emergency revoke), we need a shorter TTL or an explicit revoke channel. Decide per operational risk appetite.
  • AlarmConfirm vs AlarmAcknowledge semantics: OPC UA Part 9 distinguishes them (Ack = "I've seen this"; Confirm = "I've taken action"). Some sites only use Ack; the v2.0 model exposes both but a deployment-level policy can collapse them in practice.