Files
Agent-System-Backend/pkg/extension/project_audit.go
T
weichen 130ac344aa Guard every PostgreSQL command audit write
Why: Explicit low IDs bypassed the implicit sequence allocator and could commit after a higher audit page cutoff.

Evidence: Isolated PostgreSQL RED receipt for cross-page omission; local GREEN for explicit and default writes, trigger drift, isolation, mutation denial, offline copy rollback, retry, and sequence non-rewind. Fixed-HEAD receipts follow.

Impact: Add v34 all-writer and append-only guards; reject unsafe strong reads; make command audit storage migration exclusive and atomic with guard restoration and a forward-only sequence.

Refs: Approved W9 all-writers cutoff adjustment r1; W9 Backend task card.
2026-10-14 06:15:01 +00:00

162 lines
6.7 KiB
Go

package extension
import (
"context"
"errors"
"time"
core "agent-system-core"
)
// ProjectAuditAuthorizer answers the domain's audit.read decision for the
// authenticated actor and a project identified by trusted host storage.
// A project ID supplied by a caller is a locator, never an authorization.
type ProjectAuditAuthorizer func(actor string, projectID core.ID) (bool, error)
// ProjectAuditRegistry is optional so older extension registries remain valid.
// Only an in-process domain plugin can register this callback.
type ProjectAuditRegistry interface {
RegisterProjectAuditAuthorizer(ProjectAuditAuthorizer) error
}
var (
ErrProjectAuditDenied = errors.New("extension: project audit denied")
ErrProjectAuditUnsupported = errors.New("extension: project audit unsupported")
ErrProjectAuditLinkConflict = errors.New("extension: project audit link conflict")
ErrProjectAuditPending = errors.New("extension: committed host audit receipt pending")
)
// ProjectAuditLinkRequest binds one host command receipt to an already
// committed domain fact. Owner is never caller supplied; the host handle and
// stored audit row establish it. HostAuditOffset=0 asks the host to resolve a
// unique committed row by its trusted owner/project/OperationID, which lets an
// outbox wait for the bus audit commit after the domain handler returns. This
// first slice supports domain-commit only.
type ProjectAuditLinkRequest struct {
ProjectID core.ID `json:"projectId"`
HostAuditOffset int64 `json:"hostAuditOffset"`
SourceKind string `json:"sourceKind"`
SourceID string `json:"sourceId"`
OperationID string `json:"operationId"`
ResourceKind string `json:"resourceKind"`
ResourceID string `json:"resourceId"`
RunID string `json:"runId,omitempty"`
DisclosureClass string `json:"disclosureClass"`
IdempotencyKey string `json:"idempotencyKey"`
}
// ProjectAuditLinkProof contains the caller request plus identity and source
// fields re-read from trusted host storage. The domain verifier must check its
// committed source event and operation/resource attribution, not a model claim.
type ProjectAuditLinkProof struct {
ProjectAuditLinkRequest
Owner string
}
type ProjectAuditLinkVerifier func(context.Context, ProjectAuditLinkProof) (bool, error)
// ProjectAuditLinkedResource is the stored, immutable link presented to the
// current domain disclosure policy. A caller ID alone never authorizes it.
type ProjectAuditLinkedResource struct {
ProjectID core.ID
Owner string
SourceKind string
SourceID string
OperationID string
ResourceKind string
ResourceID string
RunID string
DisclosureClass string
}
type ProjectAuditDisclosure struct {
Metadata bool
Body bool
}
type ProjectAuditDisclosureAuthorizer func(context.Context, string, ProjectAuditLinkedResource) (ProjectAuditDisclosure, error)
// ProjectAuditPublisherFactory is issued only by successful verifier
// registration. A domain outbox may retain it and bind an owner for each job;
// owner and committed-source attribution are checked again when publishing.
// NewScopedHost(owner, pluginID) cannot mint this capability.
type ProjectAuditPublisherFactory interface {
ForOwner(owner string) (ProjectAuditPublisher, error)
}
// ProjectAuditLinkRegistry is optional. Only the registering compiled domain
// plugin receives a publisher factory; string plugin IDs carry no authority.
type ProjectAuditLinkRegistry interface {
RegisterProjectAuditLinkVerifier(ProjectAuditLinkVerifier) (ProjectAuditPublisherFactory, error)
RegisterProjectAuditDisclosureAuthorizer(ProjectAuditDisclosureAuthorizer) error
}
type ProjectAuditLinkReceipt struct {
LinkID int64 `json:"linkId"`
HostAuditOffset int64 `json:"hostAuditOffset"`
}
type ProjectAuditPublisher interface {
PublishProjectAuditLink(context.Context, ProjectAuditLinkRequest) (ProjectAuditLinkReceipt, error)
}
// ProjectAuditQuery reads one project-bound command-audit slice. Offset is a
// host source offset, not a cross-domain event sequence. A nonzero BeforeOffset
// must be paired with the CutoffOffset returned on the first page. PostgreSQL
// pages are served only while the installed generator, sequence, and all-writer
// database guards prove a
// committed prefix; otherwise the host rejects the read instead of claiming a
// fixed snapshot. See Coverage.CutoffConsistency.
type ProjectAuditQuery struct {
ProjectID core.ID `json:"projectId"`
BeforeOffset int64 `json:"beforeOffset,omitempty"`
CutoffOffset int64 `json:"cutoffOffset,omitempty"`
Limit int `json:"limit,omitempty"`
// Linked* select one exact committed link. This avoids enumerating hidden
// private links or leaking their counts before cursor policy is complete.
LinkedSourceKind string `json:"linkedSourceKind,omitempty"`
LinkedSourceID string `json:"linkedSourceId,omitempty"`
LinkedHostOffset int64 `json:"linkedHostOffset,omitempty"`
}
// HostAuditReceipt is a server-projected receipt, never a raw audit payload.
// ActorName is the name recorded with this command; old rows have no snapshot.
type HostAuditReceipt struct {
SourceOffset int64 `json:"sourceOffset"`
Time time.Time `json:"time"`
ActorID string `json:"actorId,omitempty"`
ActorName string `json:"actorName,omitempty"`
ActorNameStatus string `json:"actorNameStatus"`
CommandID string `json:"commandId"`
State string `json:"state"`
OperationID string `json:"operationId,omitempty"`
ConversationID string `json:"conversationId"`
}
// ProjectAuditCoverage states the exact scope of this first X2 slice. Missing
// host sources and old unbound rows cannot be inferred from owner-wide audit.
type ProjectAuditCoverage struct {
CommandAudit string `json:"commandAudit"`
CutoffConsistency string `json:"cutoffConsistency"`
CrossMember string `json:"crossMember"`
LegacyProjectBinding string `json:"legacyProjectBinding"`
RunRelations string `json:"runRelations"`
ModelReceipts string `json:"modelReceipts"`
BackgroundReceipts string `json:"backgroundReceipts"`
ResourceBodies string `json:"resourceBodies"`
}
type ProjectAuditPage struct {
Receipts []HostAuditReceipt `json:"receipts"`
NextOffset int64 `json:"nextOffset,omitempty"`
CutoffOffset int64 `json:"cutoffOffset"`
Coverage ProjectAuditCoverage `json:"coverage"`
}
// ProjectAuditHost is an optional, actor-bound read capability. A consumer must
// treat a failed type assertion as unsupported; an authorized page still reports
// partial coverage explicitly.
type ProjectAuditHost interface {
QueryProjectAudit(context.Context, ProjectAuditQuery) (ProjectAuditPage, error)
}