Field Classifications

Grove provides built-in field-level data classification through the @pii and @confidential annotations. These annotations drive automatic encryption at storage boundaries, control redaction in API responses, and integrate with the authorization system's classification hooks.

Prerequisites: Declaration Grammar for record and event field syntax, Authorization Hooks for classification hooks. What you'll learn: How to annotate fields, what happens at storage and retrieval boundaries, and how classification interacts with queries and audit logs.


Overview

Field classifications are metadata annotations on record and event fields that declare the sensitivity level of the data they contain. Grove recognizes two classification levels:

AnnotationSensitivityDefault Behavior
@piiPersonally Identifiable InformationEncrypted at rest, redacted in responses unless revealed
@confidentialBusiness-sensitive dataEncrypted at rest, redacted in responses unless revealed

Fields without annotations are considered "public" and are stored and returned without special treatment.

Annotating Fields

Annotations appear before the field name in record and event declarations:

record Customer {
  name: String
  @pii email: String
  @pii phone: String?
  @pii social_security: String
  @confidential credit_limit: Decimal
  tier: String
}

event CustomerCreated:1 {
  @pii name: String
  @pii email: String
  tier: String
}

Annotation Rules

  1. Placement. Annotations must appear immediately before the field name, on the same line or the preceding line.
  2. Single annotation per field. A field may have at most one classification annotation. @pii and @confidential are mutually exclusive.
  3. Inheritance. If a record field references another record type, the nested record's annotations are preserved. Classification does not propagate up or down automatically.
  4. Collections. Annotating a List or Map field classifies the entire collection, not individual elements.
record Profile {
  @pii addresses: List<Address>     // the entire list is classified as PII
  @confidential metadata: Map<String, Value>
}

Encryption at Storage Boundaries

When a classified field is persisted to a database, the Grove runtime automatically encrypts its value:

Encryption Behavior

Operation@pii@confidentialUnclassified
Write to databaseAES-256-GCM encryptedAES-256-GCM encryptedPlaintext
Read from databaseDecrypted in memoryDecrypted in memoryPlaintext
Event serializationEncrypted in event storeEncrypted in event storePlaintext
API response (default)RedactedRedactedIncluded
API response (revealed)Included (decrypted)Included (decrypted)Included
Audit logRedactedRedactedIncluded

Encryption Key Management

Database Column Handling

Classified fields are stored in database columns with a _enc suffix to distinguish them from plaintext columns:

-- Generated DDL for Customer table
CREATE TABLE customer (
  pk TEXT PRIMARY KEY,
  id TEXT NOT NULL,
  name TEXT NOT NULL,
  email_enc BLOB NOT NULL,       -- @pii: encrypted
  phone_enc BLOB,                -- @pii: encrypted, nullable
  social_security_enc BLOB NOT NULL,  -- @pii: encrypted
  credit_limit_enc BLOB NOT NULL,     -- @confidential: encrypted
  tier TEXT NOT NULL,
  version INTEGER NOT NULL,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);

Redaction in API Responses

By default, classified fields are redacted in API responses. The redacted value depends on the field type:

Field TypeRedacted Value
String"[REDACTED]"
Int, Decimal0
Boolfalse
Id"[REDACTED]"
List<T>[]
Map<K,V>{}
Optional (T?)null

Revealing Classified Fields

The classification hook determines which fields are visible to a given caller:

classification(ctx) {
  if ctx.principal.roles.contains("support:full") {
    reveal @pii
    reveal @confidential
  } else if ctx.principal.roles.contains("support:basic") {
    reveal @pii
  }
  // Without explicit reveal, fields remain redacted
}

See Authorization Hooks for full details on the classification hook.

Classified Fields in Queries

When a query accesses a classified field, the classification is preserved through the query result. The field is decrypted in memory during query execution but redacted in the response according to the caller's classification hook.

query get_customer(id: Id) -> Customer {
  // email is decrypted for query logic
  let customer = customers.get(id)
  return customer  // email will be redacted in the response unless revealed
}

Filtering on Classified Fields

You may filter on classified fields within queries. The runtime decrypts the field for comparison but does not expose it in the response unless revealed:

query find_by_email(email: String) -> Customer? {
  return customers.find(c => c.email == email)
}

Note: Filtering on encrypted fields requires full-table scan since encrypted values cannot be indexed. For high-performance lookups on PII fields, consider maintaining a separate hashed index.

Classified Fields in Audit Logs

Classified fields are always redacted in audit logs, regardless of the caller's classification level. This ensures that sensitive data does not leak into the audit trail.

audit update_customer(ctx, result) {
  log {
    action: "update_customer",
    customer_id: result.id,
    // Do NOT log PII fields
    updated_fields: result.changed_fields,
    principal: ctx.principal.id,
  }
}

Best Practices

  1. Classify at the source. Apply annotations when defining the record or event, not after the fact. This ensures encryption is applied from the first write.

  2. Minimize PII in events. Events are immutable. Once PII is written to the event store (even encrypted), it persists forever. Consider storing a reference (ID) to the PII rather than the PII itself.

  3. Use @confidential for business data. Reserve @pii for data that identifies a natural person. Use @confidential for sensitive business data like pricing, financial terms, or internal strategies.

  4. Test redaction. Write tests that verify classified fields are properly redacted for unauthorized callers:

    test "customer email is redacted for unauthenticated requests" {
      let customer = create_customer(name: "Alice", email: "alice@example.com")
      let result = get_customer(customer.id)
      assert result.email == "[REDACTED]"
    }
    

See Also