Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions internal/adapter/store/sqlite/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ type agentRow struct {
ACMEChallengeExpiresAtMs sql.NullInt64 `db:"acme_challenge_expires_at_ms"`
CapabilitiesHash sql.NullString `db:"capabilities_hash"`
DNSRecordStyle sql.NullString `db:"dns_record_style"`
AnchorType sql.NullString `db:"anchor_type"`
AnchorResolvedID sql.NullString `db:"anchor_resolved_id"`
CreatedAtMs int64 `db:"created_at_ms"`
UpdatedAtMs int64 `db:"updated_at_ms"`
}
Expand Down Expand Up @@ -88,9 +90,33 @@ func (r agentRow) toDomain() (*domain.AgentRegistration, error) {
if r.DNSRecordStyle.Valid {
reg.DNSRecordStyle = domain.DNSRecordStyle(r.DNSRecordStyle.String)
}
// AnchorClaim is reconstructed from anchor_type + anchor_resolved_id.
// PublicKeyJWK and IssuedAt are intentionally not persisted: verifiers
// re-resolve the claim through the AnchorResolver on demand to honor
// the per-profile freshness budget. Pre-Plan-G rows have anchor_type
// NULL and surface a nil AnchorClaim downstream.
if r.AnchorType.Valid && r.AnchorType.String != "" {
reg.AnchorClaim = &domain.IdentityClaim{
AnchorType: domain.AnchorType(r.AnchorType.String),
}
if r.AnchorResolvedID.Valid {
reg.AnchorClaim.ResolvedID = r.AnchorResolvedID.String
}
}
return reg, nil
}

// anchorClaimColumns returns the (anchor_type, anchor_resolved_id)
// values for the INSERT INTO agent_registrations row. Both are NULL
// when the aggregate carries no AnchorClaim (legacy FQDN-only path).
func anchorClaimColumns(claim *domain.IdentityClaim) (sql.NullString, sql.NullString) {
if claim == nil || claim.AnchorType == "" {
return sql.NullString{}, sql.NullString{}
}
return sql.NullString{String: string(claim.AnchorType), Valid: true},
sql.NullString{String: claim.ResolvedID, Valid: true}
}

// Save inserts or updates an AgentRegistration. Endpoints, server cert,
// and identity CSR are persisted via their dedicated tables — Save only
// writes the root aggregate row.
Expand All @@ -110,15 +136,19 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration)
acme_dns01_token, acme_challenge_expires_at_ms,
capabilities_hash,
dns_record_style,
anchor_type, anchor_resolved_id,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
// AnsName is the zero value for §3.2.0 base-only registrations.
// Persist it as NULL so the UNIQUE constraint allows multiple
// base-only rows (SQLite treats each NULL as distinct under
// UNIQUE), and read the FQDN from agent.FQDN() which falls back
// to agent.AgentHost when AnsName is zero. AnsName.String()
// returns "" for the zero value; nullableString promotes it to
// SQL NULL.
// SQL NULL. AnchorClaim is the verified IdentityClaim from the
// caller; pre-Plan-G code paths leave it nil and the columns
// stay NULL.
anchorType, anchorResolvedID := anchorClaimColumns(agent.AnchorClaim)
res, err := s.db.extx(ctx).ExecContext(ctx, q,
agent.AgentID,
agent.OwnerID,
Expand All @@ -135,6 +165,8 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration)
nullableMs(agent.ACMEChallenge.ExpiresAt),
nullableString(agent.CapabilitiesHash),
nullableString(string(agent.DNSRecordStyle)),
anchorType,
anchorResolvedID,
now, now,
)
if err != nil {
Expand Down
93 changes: 93 additions & 0 deletions internal/adapter/store/sqlite/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,99 @@ func TestAgentStore_ExistsActiveBaseOnlyByAgentHost(t *testing.T) {
}
}

func TestAgentStore_AnchorClaim_RoundTrip(t *testing.T) {
db := newTestDB(t)
store := NewAgentStore(db)
ctx := context.Background()

cases := []struct {
name string
claim *domain.IdentityClaim
}{
{
name: "FQDN claim",
claim: &domain.IdentityClaim{
AnchorType: domain.AnchorTypeFQDN,
ResolvedID: "agent.example.com",
},
},
{
name: "DID claim",
claim: &domain.IdentityClaim{
AnchorType: domain.AnchorTypeDID,
ResolvedID: "did:web:agent.example.com",
},
},
{
name: "LEI claim",
claim: &domain.IdentityClaim{
AnchorType: domain.AnchorTypeLEI,
ResolvedID: "529900T8BM49AURSDO55",
},
},
{
name: "no claim (legacy path)",
claim: nil,
},
}

for i, c := range cases {
t.Run(c.name, func(t *testing.T) {
// Distinct host + agent ID per case so the per-FQDN
// base-only uniqueness check does not interfere.
agentID := "agent-anchor-" + strconvI(i)
host := "agent" + strconvI(i) + ".example.com"
fixture := newBaseOnlyFixture(t, agentID, host)
fixture.AnchorClaim = c.claim

if err := store.Save(ctx, fixture); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, err := store.FindByID(ctx, fixture.ID)
if err != nil {
t.Fatalf("FindByID: %v", err)
}
if c.claim == nil {
if loaded.AnchorClaim != nil {
t.Errorf("expected nil AnchorClaim, got %+v", loaded.AnchorClaim)
}
return
}
if loaded.AnchorClaim == nil {
t.Fatal("expected AnchorClaim, got nil")
}
if loaded.AnchorClaim.AnchorType != c.claim.AnchorType {
t.Errorf("AnchorType: got %q, want %q",
loaded.AnchorClaim.AnchorType, c.claim.AnchorType)
}
if loaded.AnchorClaim.ResolvedID != c.claim.ResolvedID {
t.Errorf("ResolvedID: got %q, want %q",
loaded.AnchorClaim.ResolvedID, c.claim.ResolvedID)
}
// PublicKeyJWK is intentionally not persisted; verifiers
// re-resolve through the AnchorResolver to honor the
// per-profile freshness budget.
if len(loaded.AnchorClaim.PublicKeyJWK) != 0 {
t.Errorf("PublicKeyJWK should not be persisted, got %d bytes",
len(loaded.AnchorClaim.PublicKeyJWK))
}
})
}
}

// strconvI keeps the test imports minimal.
func strconvI(n int) string {
if n == 0 {
return "0"
}
digits := []byte{}
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
return string(digits)
}

// TestAgentStore_ExistsByAnsName_ZeroIsFalse pins the contract
// guarding base-only registrations: looking up the zero-value AnsName
// must short-circuit to false rather than match every empty ans_name
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- 009_agent_anchor_claim.sql
--
-- Plan G Slice 5b: persist the AnchorClaim that produced a
-- registration. ANS-0 admits three anchor types (fqdn, did, lei);
-- the storage shape captures the type plus the canonical resolved
-- ID. The verification key (PublicKeyJWK) is intentionally NOT
-- stored on this row — verifiers re-resolve through the
-- AnchorResolver to honor the per-profile freshness budget, and
-- a stale stored JWK would defeat that.
--
-- Both columns are nullable. Pre-Plan-G registrations have no
-- AnchorClaim recorded; their anchor_type stays NULL and the
-- aggregate exposes a nil AnchorClaim downstream. The application
-- layer infers an FQDN claim from agent_host for those legacy rows
-- when needed without writing into this column on read.
--
-- Adding columns to an existing SQLite table is a simple ALTER;
-- no rebuild ceremony required.

ALTER TABLE agent_registrations
ADD COLUMN anchor_type TEXT;

ALTER TABLE agent_registrations
ADD COLUMN anchor_resolved_id TEXT;
47 changes: 42 additions & 5 deletions internal/domain/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,20 @@ type AgentRegistration struct {
// is treated as DefaultDNSRecordStyle by ComputeRequiredDNSRecords.
DNSRecordStyle DNSRecordStyle `json:"dnsRecordStyle,omitempty"`

// AnchorClaim records the verified IdentityClaim that produced
// this registration, when the caller supplied one through ANS-0's
// AnchorResolver port. Nil for legacy FQDN-only deployments where
// the registration's identity is implicit in AgentHost (and AnsName
// when versioned). When non-nil, AnchorClaim.AnchorType authoritatively
// identifies the anchor profile — non-FQDN profiles (DID, LEI) force
// base-only registration invariants because the X.509 URI SAN binding
// the versioned ANS-2 path relies on does not apply.
//
// Slice 5a stores AnchorClaim in-memory only; persistence lands in
// Slice 5b through migration 009 (anchor_type + anchor_resolved_id
// columns on agent_registrations).
AnchorClaim *IdentityClaim `json:"anchorClaim,omitempty"`

// PendingEvents holds domain events raised during this aggregate operation.
// They are cleared after being published.
PendingEvents []Event `json:"-"`
Expand All @@ -157,6 +171,13 @@ type AgentRegistration struct {
// Mixed forms are rejected: a version requires an Identity CSR (and
// vice versa) — the Identity Certificate's URI SAN encodes the
// ANSName, so the two artifacts are coupled.
//
// Non-FQDN anchors (DID, LEI) force the base-only path: today the
// versioned URI SAN binding is FQDN-shaped and a future amendment
// will admit DID-shaped URIs into the SAN. Until then, an
// AnchorClaim with anchorType in {did, lei} accompanied by a non-zero
// ansName or non-nil identityCSR is rejected with
// NON_FQDN_REQUIRES_BASE_ONLY.
func NewRegistration(
agentID string,
ownerID string,
Expand All @@ -167,6 +188,7 @@ func NewRegistration(
endpoints []AgentEndpoint,
serverCert *ByocServerCertificate,
identityCSR *AgentCSR,
anchorClaim *IdentityClaim,
now time.Time,
) (*AgentRegistration, error) {
if agentID == "" {
Expand All @@ -190,6 +212,20 @@ func NewRegistration(
)
}

// Non-FQDN anchors force base-only until ANS-2 admits a DID-shaped URI
// SAN. A versioned (or CSR-bearing) DID/LEI registration is rejected
// at the boundary so the aggregate's invariants stay coherent.
if anchorClaim != nil &&
anchorClaim.AnchorType != "" &&
anchorClaim.AnchorType != AnchorTypeFQDN &&
!baseOnly {
return nil, NewValidationError(
"NON_FQDN_REQUIRES_BASE_ONLY",
fmt.Sprintf("anchor type %q must be registered as base-only (no version, no identity CSR) until ANS-2 admits non-FQDN URI SANs",
anchorClaim.AnchorType),
)
}

// Resolve canonical FQDN. Versioned: ansName carries it. Base-only:
// caller passes agentHost explicitly.
fqdn := strings.ToLower(strings.TrimSpace(agentHost))
Expand Down Expand Up @@ -242,11 +278,12 @@ func NewRegistration(
}

reg := &AgentRegistration{
AgentID: agentID,
OwnerID: ownerID,
AnsName: ansName,
AgentHost: fqdn,
Status: StatusPendingValidation,
AgentID: agentID,
OwnerID: ownerID,
AnsName: ansName,
AgentHost: fqdn,
AnchorClaim: anchorClaim,
Status: StatusPendingValidation,
Details: RegistrationDetails{
RegistrationTimestamp: now,
DisplayName: displayName,
Expand Down
Loading