sdk(P1.6): client-side head tracking — your SDK is a fork detector
Completes the verification chain (P1.2 -> P1.1 -> P1.3 -> P1.6). The client remembers the last accepted (seq_no, event_hash) per stream and checks every new receipt links forward; if the server rewinds a sequence number or presents a divergent lineage, log_event / log_events raise AttestoForkDetected (Go: *ForkDetectedError) and the stored head is NOT advanced. The customer's own machine becomes the fork detector — no trust in any Attesto-side check. - Python: HeadStore protocol + FileHeadStore (~/.attesto/heads.json, atomic, 0600, default) + MemoryHeadStore; wired into sync and async v2 clients; head_store=None disables. - TypeScript: HeadStore + MemoryHeadStore (default, edge-safe); Node-only FileHeadStore kept in a separate module (@attesto/sdk/heads-file) so the core bundle imports no node:fs; headStore: null disables. - Go: HeadStore interface + MemoryHeadStore (default) + NewFileHeadStore; WithHeadStore option; WithHeadStore(nil) disables. Same forward/rewind/divergence/gap semantics across all three (unit-tested: in-order advance, forged-rewind fork, divergent-next fork, forward-gap accept, file-store restart persistence). Existing v2 client tests pin head_store=None (they replay overlapping seq). READMEs gain a "Your SDK is a witness" section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
42
client.go
42
client.go
@@ -23,6 +23,7 @@ type Client struct {
|
||||
maxRetries int
|
||||
userAgent string
|
||||
validateKey bool
|
||||
headStore HeadStore
|
||||
}
|
||||
|
||||
type Option func(*Client) error
|
||||
@@ -51,6 +52,7 @@ func newClient(bearer string, validateKey bool, opts ...Option) (*Client, error)
|
||||
maxRetries: 3,
|
||||
userAgent: "attesto-go/" + SDKVersion,
|
||||
validateKey: validateKey,
|
||||
headStore: NewMemoryHeadStore(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if err := opt(client); err != nil {
|
||||
@@ -112,6 +114,16 @@ func WithUserAgent(userAgent string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeadStore sets the store used for client-side fork detection. The default
|
||||
// is an in-memory store; pass NewFileHeadStore(path) to persist across process
|
||||
// invocations, or nil to disable fork detection.
|
||||
func WithHeadStore(store HeadStore) Option {
|
||||
return func(c *Client) error {
|
||||
c.headStore = store
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) CreateStream(ctx context.Context, input StreamCreateInput, options ...RequestOptions) (*Stream, error) {
|
||||
var out Stream
|
||||
if input.Metadata == nil {
|
||||
@@ -172,8 +184,23 @@ func (c *Client) LogEvent(ctx context.Context, streamID string, input EventInput
|
||||
}
|
||||
}
|
||||
var out EventReceipt
|
||||
err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, idempotency(options), &out)
|
||||
return &out, err
|
||||
if err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, idempotency(options), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.trackHead(out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// trackHead checks the receipt extends the stored head (returning a
|
||||
// *ForkDetectedError on a rewind/divergence) and advances the store. No-op when
|
||||
// fork detection is disabled (headStore is nil).
|
||||
func (c *Client) trackHead(receipt EventReceipt) error {
|
||||
if c.headStore == nil {
|
||||
return nil
|
||||
}
|
||||
return checkAndAdvanceHead(c.headStore, receipt)
|
||||
}
|
||||
|
||||
func (c *Client) LogEvents(ctx context.Context, streamID string, events []EventInput, options ...RequestOptions) (*EventBatchResponse, error) {
|
||||
@@ -196,8 +223,15 @@ func (c *Client) LogEvents(ctx context.Context, streamID string, events []EventI
|
||||
}
|
||||
body := M{"events": events}
|
||||
var out EventBatchResponse
|
||||
err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events/batch", nil, body, idempotency(options), &out)
|
||||
return &out, err
|
||||
if err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events/batch", nil, body, idempotency(options), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, receipt := range out.Receipts {
|
||||
if err := c.trackHead(receipt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetReceipt(ctx context.Context, streamEventID string) (*EventReceipt, error) {
|
||||
|
||||
Reference in New Issue
Block a user