Produced 2026-05-24 by CC.
This document grounds Claude.ai's understanding of FORAY's adapter pattern across all available material on the local filesystem and the live foray.dunin7.com site. Companion to:
/Users/dunin7/Downloads/foray-protocol-deep-read-v0_1.md/Users/dunin7/Downloads/foray-challenge-deep-read-v0_1.mdScope: comprehensive. All adapter material read, all detail preserved. The Operator's standing instruction — "look at everything, we cannot afford to miss anything during an investigation" — has been honored.
Up-front orientation: the word "adapter" carries two distinct meanings in FORAY material. Both meanings are present in the canonical material and conflating them produces misleading conclusions about how Loomworks would integrate FORAY. The two meanings:
quickbooks-adapter.js and salesforce-adapter.js are examples. These adapters consume ERP/CRM data and produce structured FORAY transactions.dunin7-foray Node package (v3.0.0) is the reference PostgreSQL persistence adapter. The "Pluggable Persistence Architecture" doc defines this layer formally.
The protocol deep-read found foray-sdk.js referenced but absent. The reason for that absence is now clear: the SDK shape implied by the kaspathon adapters (createArrangement / createAccrual / ... / anchorToBlockchain) is not the SDK that DUNIN7 actually ships. The shipped SDK is dunin7-foray v3.0.0, which has a fundamentally different shape — emit(chainId, eventType, triggeredBy, payload) against a PostgreSQL chain rather than createArrangement(...) against blockchain anchoring. The kaspathon adapters were written against a hypothetical SDK that never materialised in the shape they assumed; the actual shipped SDK is what dunin7-workforce uses today.
This bifurcation is the load-bearing finding of this deep-read.
| Path | Lines | Size | Role |
|---|---|---|---|
| /Users/dunin7/foray-kaspathon/quickbooks-adapter.js | 627 | 20 KB | Source-side adapter (QuickBooks Online) — demo |
| /Users/dunin7/foray-kaspathon/salesforce-adapter.js | 801 | 26 KB | Source-side adapter (Salesforce) — demo |
| /Users/dunin7/foray-kaspathon/foray-api-server.js | ~1,000 | 41 KB | LLM-backed natural-language → 4A generator (not an adapter; reviewed for completeness) |
| /Users/dunin7/foray-kaspathon/proxy-server.js | 100 | 3 KB | CORS proxy for the generator endpoints (not an adapter) |
| /Users/dunin7/dunin7-foray/lib/index.mjs | 7 | — | Public API barrel for the live SDK |
| /Users/dunin7/dunin7-foray/lib/foray-client.mjs | 374 | — | The live FORAY SDK — ForayClient class, PostgreSQL persistence + hash chain + attestation queries |
| /Users/dunin7/dunin7-foray/lib/attestation.mjs | 29 | — | FORAY v4.1 attestation/attestor/outcome constants |
| /Users/dunin7/dunin7-foray/lib/evidence.mjs | 46 | — | Hash + subject-ref + evidence-ref helpers |
| /Users/dunin7/dunin7-foray/lib/canonical-json.mjs | 14 | — | Deterministic JSON key-ordering for stable hashing |
| /Users/dunin7/dunin7-foray/test/smoke.mjs | 97 | — | SDK smoke test exercising the full Client lifecycle |
| /Users/dunin7/dunin7-foray/package.json | — | — | Confirms name: dunin7-foray, version: 3.0.0 |
| /Users/dunin7/dunin7-foray-validate/src/index.ts | 8 | — | TypeScript predecessor SDK — public API barrel |
| /Users/dunin7/dunin7-foray-validate/src/emitter/emitter.ts | 92 | — | FORAYEmitter class (TypeScript) |
| /Users/dunin7/dunin7-foray-validate/src/stores/pg-store.ts | 139 | — | FORAYStore class (TypeScript) |
| /Users/dunin7/dunin7-foray-validate/src/verifier/chain.ts | 109 | — | computeHash, verifyEventHash, verifyChain |
| /Users/dunin7/dunin7-foray-validate/src/events/types.ts | 86 | — | FORAYEventType discriminated union (50+ event types) |
| /Users/dunin7/dunin7-foray-validate/src/validate.ts | 200 | — | G0.5 gate validation script — 20 synthetic events + integrity tests |
| /Users/dunin7/mode-b-build/dunin7-foray/lib/foray-client.mjs | — | — | Earlier v2.x of the same SDK (no attestation, no canonical-json, no mutex). Diffed against current; differences are docstring + attestation-support adds |
| /Users/dunin7/dunin7-workforce/tests/foray-client.test.mjs | — | — | Workforce-side test exercising ForayClient.emit + verifyChain against canonical-JSON nested payloads |
| /Users/dunin7/dunin7-workforce/FORAY_SYSTEM_KEY_ARCHITECTURE.md | ~110 | — | How dunin7-workforce wires the live SDK (foray_system_key per project, registered ForayClient per system) |
| Path | Source | Lines | Role |
|---|---|---|---|
| /Users/dunin7/foray-kaspathon/integration-guide.html | foray.dunin7.com (local) | ~700 | Adapter Architecture section, per-ERP integration patterns, batch-vs-realtime |
| /Users/dunin7/foray-kaspathon/docs.html | foray.dunin7.com (local) | ~1,250 | "FORAY adapters simply translate what exists", AI Agent Integration section (4A → agent decision cycle) |
| /Users/dunin7/foray-kaspathon/specification.html | foray.dunin7.com (local) | — | Schema definitions (cross-reference; covered in protocol deep-read) |
| /Users/dunin7/foray-kaspathon/about.html | foray.dunin7.com (local) | — | Project framing |
| /Users/dunin7/foray-kaspathon/index.html | foray.dunin7.com (local) | — | Marketing landing — cross-checked |
| /Users/dunin7/foray-kaspathon/demo.html | foray.dunin7.com (local) | — | Demo embed (not adapter material) |
| /Users/dunin7/dunin7-docs/foray/FORAY_PLUGGABLE_PERSISTENCE_V1_0_0.md | DUNIN7-internal | 177 | The persistence-adapter pluggable-pipeline spec. Defines ForayPersistenceAdapter interface and admin-driven fan-out. Concept doc, dated 2026-02-19 |
| /Users/dunin7/Desktop/foray-updates/FORAY_AI_Generator_Setup.md | DUNIN7-internal | — | Setup guide for the foray-api-server.js LLM endpoint (not adapter material per se) |
https://foray.dunin7.com/ returned HTTP 200 (48 KB). The local clone at /Users/dunin7/foray-kaspathon/ is identical content per spot-check.
/Users/dunin7/foray-kaspathon — git pull --ff-only succeeded; HEAD 18727b4/Users/dunin7/foray-protocol-db — already at HEAD per prior deep-read (5f06eea)/Users/dunin7/dunin7-foray — local-only checkout (no git origin checked)/Users/dunin7/dunin7-foray-validate — local-only checkout/Users/dunin7/tessera/src/foray/ (existed but skimmed; not adapter source)/Users/dunin7/loomworks-record/protocols/foray/ (record-side, not adapter source)/Users/dunin7/.venvs/foray_challenge/ (Python venv, not adapter source)
From integration-guide.html (the canonical adapter framing):
> "Every FORAY adapter follows the same three-phase architecture, regardless of source system: > > Phase 1: Extract > - Connect to source system API (REST, SOAP, BAPI, etc.) > - Pull transaction data with all relevant fields > - Handle pagination for batch operations > > Phase 2: Transform > - Map source fields to FORAY 4A components > - Apply privacy obfuscation (hash parties, round amounts) > - Generate component hashes (SHA-256) > - Compute merkle root > > Phase 3: Anchor > - Submit merkle root to Kaspa blockchain > - Receive transaction ID and block confirmation > - Store full FORAY JSON with anchor reference > - Return confirmation to source system (optional)"
The accompanying ASCII diagram:
Source System (SAP, Oracle, QuickBooks, Salesforce, ...)
|
v
FORAY Adapter (extracts + transforms)
|
v
FORAY JSON (4A components + hashes)
|
v
Kaspa Blockchain (merkle root anchored)
|
v
Archive (full JSON + anchor reference)
This is the source-side adapter meaning. The integration guide treats this as the universal pattern.
From docs.html (the "Integration Overview" section):
> "Zero Additional Data Entry — If your ERP system already processes your business transactions, you already have everything FORAY needs."
> "FORAY does not require new data — it works with what you already capture. When you record a sale, process payroll, or log a purchase order, your ERP system already stores the parties, amounts, dates, and terms that define that transaction."
> "FORAY adapters simply translate what exists" — verbatim section header.
The mapping table reproduced in docs.html:
| Your ERP Already Has | FORAY Organizes It As | |---|---| | Customer/Vendor records | Arrangements (who agreed to what) | | Calculated amounts, taxes, allocations | Accruals (how values were derived) | | Scheduled payments, deliveries | Anticipations (what is expected) | | Completed payments, shipments | Actions (what actually happened) |
And the load-bearing constraint:
> "FORAY Cannot Create Additional Data — This is by design. FORAY Protocol is a read-only audit layer — it can only organize, structure, and anchor data that already exists in your systems." > > "- No fabrication: FORAY cannot invent transactions, amounts, or parties > - No modification: FORAY cannot alter your source records > - No insertion: FORAY cannot add data back into your ERP"
> "This separation ensures audit integrity. The blockchain-anchored FORAY record is a reflection of your source system at a point in time — nothing more, nothing less."
The adapter pattern exists because FORAY is a reflection layer, not a system of record. The adapter's job is to construct a tamper-evident reflection of the source system's existing events without ever writing back to the source.
Reading across kaspathon's two source adapters and the persistence-side ForayPersistenceAdapter interface from the Pluggable Persistence doc, the recurring contract is:
_withRetry).Invoice, Bill, Payment, CreditMemo, JournalEntry. Salesforce deals in Opportunity, Quote, Order, Case, Account, Lead. Each requires bespoke field mapping.Account → Arrangement + Accrual only (no Anticipation or Action — Accounts don't settle). Salesforce Lead → Arrangement + Action (if converted). QuickBooks CreditMemo → Arrangement + Accrual + Action (no Anticipation — applied immediately).minimal | standard | high | defense) but the defaults vary: QuickBooks rounds to nearest \$100; Salesforce rounds to nearest \$1,000.Per the kaspathon adapter source (the hypothetical model):
Source System → Adapter → SDK → Anchor Service
(constructs) (persists)
Per the actual deployed model (dunin7-foray v3.0.0):
Source System → Adapter (does not exist for live customers)
Application code calling SDK directly
↓
ForayClient
↓
PostgreSQL `foray_events` table
(hash-chained, append-only)
In the live model, there is no separate "anchor service." The SDK is the persistence boundary. The PostgreSQL database is the persistence layer. Blockchain anchoring is anticipated by the Pluggable Persistence doc but not implemented in dunin7-foray v3.0.0.
The kaspathon adapters are stateful in two narrow senses:
entitySalt (random 32 bytes) for cross-transaction unlinkability of hashed identifiers.lastRequestTime for rate limiting.They are stateless in the broader sense:
The live ForayClient is stateful in a different way:
ChainMutex class) to serialise concurrent writes.Per integration-guide.html (the canonical answer): adapters are described as running adjacent to the source system, not in-process. The diagram shows the adapter as a distinct hop between source and FORAY. The implementation language is unspecified (the kaspathon adapters happen to be Node.js).
Per the actual dunin7-foray SDK usage in dunin7-workforce: the SDK runs in-process with the application. Workforce's API routes import ForayClient and call .emit() directly during request handling. There is no out-of-process adapter; the application is the adapter.
These are different patterns. The kaspathon adapters model a deployment where FORAY integration is a separable process; the dunin7-foray SDK models a deployment where FORAY is a library called inline by the application.
Path: /Users/dunin7/foray-kaspathon/quickbooks-adapter.js
Lines: 627
Header version: v2.0.0 (Corrected)
License: BSL-1.1
Disclaimer (verbatim from source):
> "DISCLAIMER: This code is provided for demonstration purposes only. Production implementations require additional security hardening, error handling, testing, and validation. Consult qualified professionals before deploying in production environments."
Source system: QuickBooks Online (QBO).
Source-system data shape consumed: QBO REST API objects via a qbo client (passed in via config). Six transaction types handled:
{Id, DocNumber, CompanyInfo, CustomerRef, SalesTermRef, DueDate, CurrencyRef, TotalAmt, Balance, TxnTaxDetail, Line, TxnDate}{Id, DocNumber, CompanyInfo, VendorRef, SalesTermRef, DueDate, CurrencyRef, TotalAmt, Balance, TxnDate}{Id, DocNumber, TotalAmt, ...} (validated for TotalAmt presence){Id, DocNumber, CompanyInfo, CustomerRef, PrivateNote, LinkedTxn, TotalAmt, CurrencyRef, TxnDate}{Id, DocNumber, CompanyInfo, Line[{JournalEntryLineDetail.PostingType, Amount}], PrivateNote, Adjustment, CurrencyRef, TxnDate}FORAY component construction logic (Invoice example, lines 215-319):
// Step 1: Arrangement (invoice terms, parties)
const arrangement = this.sdk.createArrangement({
source_system: 'quickbooks',
transaction_type: 'invoice',
reference_id: this._hashIdentifier(invoice.Id, 'invoice'),
parties: [
{ role: 'seller', identifier: this._hashIdentifier(invoice.CompanyInfo?.CompanyName, 'company'), ... },
{ role: 'buyer', identifier: this._hashIdentifier(invoice.CustomerRef?.name, 'customer'), ... }
],
terms: {
payment_terms: invoice.SalesTermRef?.name || 'Net 30',
due_date: invoice.DueDate,
currency: invoice.CurrencyRef?.value || 'USD'
}
});
// Step 2: Accrual (revenue recognition)
const accrual = this.sdk.createAccrual({
arrangement_ref: arrangement.id,
amount: this._obfuscateAmount(invoice.TotalAmt),
currency: invoice.CurrencyRef?.value || 'USD',
formula_id: 'invoice_total',
formula_description: 'Sum of line items + tax',
formula_inputs: {
line_item_count: invoice.Line?.length || 0,
tax_amount: this._obfuscateAmount(invoice.TxnTaxDetail?.TotalTax || 0),
subtotal: this._obfuscateAmount(invoice.TotalAmt - (invoice.TxnTaxDetail?.TotalTax || 0))
},
debit_account: 'Accounts Receivable',
credit_account: 'Revenue',
fiscal_period: this._getFiscalPeriod(invoice.TxnDate)
});
// Step 3: Anticipation (expected payment)
const anticipation = this.sdk.createAnticipation({
arrangement_ref: arrangement.id,
expected_date: invoice.DueDate,
expected_amount: this._obfuscateAmount(invoice.TotalAmt),
currency: invoice.CurrencyRef?.value || 'USD',
settlement_account: 'Cash',
conditions: { payment_method: 'Any accepted method', partial_payment_allowed: true }
});
// Step 4: Action (only if paid)
let action = null;
if (invoice.Balance === 0 && invoice.TotalAmt > 0) {
action = this.sdk.createAction({
arrangement_ref: arrangement.id,
anticipation_ref: anticipation.id,
settlement_type: 'full_payment',
actual_amount: this._obfuscateAmount(invoice.TotalAmt),
settlement_date: invoice.TxnDate,
payment_method: 'Applied'
});
}
// Anchor to blockchain
const transaction = this.sdk.createTransaction({
components: { arrangement, accrual, anticipation, action }
});
const anchorResult = await this.sdk.anchorToBlockchain(transaction);
Mapping decisions visible in the code:
Balance === 0 && TotalAmt > 0 is the rule for "this is now an Action" (i.e., paid). An unpaid invoice generates only Arrangement + Accrual + Anticipation.-creditMemo.TotalAmt) and an immediate Action; no Anticipation (credits are applied immediately).PostingType === 'Debit') to compute the Accrual amount.vendor/company roles and Expense/Accounts Payable accounts.State management:
entitySalt (32-byte random, used as a per-adapter cryptographic salt for identifier hashing — this is what gives cross-transaction unlinkability).lastRequestTime (epoch ms, for rate limiting).config object with privacyLevel, retryAttempts, retryDelayMs, enableLogging, minRequestInterval.Integration mechanics:
new QuickBooksForayAdapter({ quickbooks: qboClient, foray: forayConfig }).adapter.anchorInvoice(invoiceObject) or adapter.batchProcess([...invoices], 'Invoice'). There is no webhook subscriber, no polling loop. The application decides when to anchor.Error handling:
ValidationError class for input-validation failures with a field discriminator.validateConfig() runs at construction time (config object shape, privacy level enum).validateTransaction() runs at the start of every anchor method (per-type required fields)._withRetry() wraps every anchor method with configurable retries (default 3) and exponential-ish backoff (retryDelayMs * attempt)._enforceRateLimit() queues calls behind a minimum inter-request interval.batchProcess() swallows per-item failures into an errors[] array rather than failing the whole batch; returns {total, succeeded, failed, results, errors}.sdk.anchorToBlockchain throws, retry wrapper retries 3 times, then the per-anchor method throws to the caller. No queueing or deferred-anchor mechanism.Performance characteristics:
batchProcess is sequential, not parallel — for (let i = 0; i < transactions.length; i++) { await method(transaction); }.Code structure:
ValidationError class, validateConfig(), validateTransaction(), then QuickBooksForayAdapter class._log → _withRetry → _delay → _enforceRateLimit → _obfuscateAmount → _hashIdentifier → anchorInvoice → anchorBill → anchorCreditMemo → anchorJournalEntry → batchProcess → _getFiscalPeriod.module.exports = QuickBooksForayAdapter; module.exports.ValidationError = ValidationError;.require('./foray-sdk'), require('crypto').
Observation: the file header lists "PurchaseOrder, SalesReceipt" as supported, but anchorPurchaseOrder and anchorSalesReceipt methods do not exist in the source. The header is aspirational beyond what the code implements.
Path: /Users/dunin7/foray-kaspathon/salesforce-adapter.js
Lines: 801
Header: "FORAY Salesforce Adapter (CORRECTED v2.0)"
License: BSL-1.1 (implied; same as quickbooks)
Same disclaimer block as quickbooks-adapter.
Source system: Salesforce (any edition with the standard objects).
Source-system data shape consumed: Salesforce REST API or jsforce-style object representations. Six object types handled (no Contract handler implemented despite being in the type map):
{Id, AccountId, OwnerId, StageName, Type, LeadSource, CreatedDate, Amount, Probability, CurrencyIsoCode, ForecastCategoryName, CloseDate, NextStep, IsClosed, IsWon}{Id, OpportunityId, AccountId, QuoteNumber, Status, ExpirationDate, CreatedDate, Subtotal, Discount, Tax, ShippingHandling, GrandTotal, TotalPrice, LastModifiedDate}{Id, AccountId, OrderNumber, Type, Status, EffectiveDate, EndDate, TotalAmount, ActivatedDate}{Id, AccountId, ContactId, CaseNumber, Type, Priority, Origin, Subject, CreatedDate, IsEscalated, IsClosed, ClosedDate, Status, Reason}{Id, OwnerId, Type, Industry, Rating, CreatedDate, Name, AnnualRevenue, NumberOfEmployees}{Id, OwnerId, Status, LeadSource, Rating, CreatedDate, Company, IsConverted, ConvertedDate, ConvertedAccountId, ConvertedContactId, ConvertedOpportunityId}FORAY component construction logic (Opportunity example, lines 226-333):
const arrangement = this.foray.createArrangement({
parties: [accountId, ownerId].filter(Boolean),
asset_type: this.objectTypeMap['Opportunity'], // 'sales-opportunity'
terms: {
stage: this.hashValue(opportunity.StageName),
type: this.hashValue(opportunity.Type),
lead_source: this.hashValue(opportunity.LeadSource),
created_date: opportunity.CreatedDate
},
effective_date: opportunity.CreatedDate,
metadata: { salesforce_id: opportunityId, object_type: 'Opportunity' }
});
const accrual = this.foray.createAccrual({
arrangement_hash: arrangement.hash, // NOTE: uses .hash not .id
formula_id: this.foray.getFormulaId('weighted_revenue'),
inputs: {
amount: this.obfuscateAmount(opportunity.Amount || 0),
probability: opportunity.Probability || 0,
currency: this.hashValue(opportunity.CurrencyIsoCode || 'USD')
},
outputs: {
expected_revenue: this.obfuscateAmount(amount * (probability / 100)),
risk_adjusted_value: this.obfuscateAmount(amount * (probability / 100) * 0.85)
},
calculation_date: new Date().toISOString(),
metadata: { stage: ..., forecast_category: ... }
});
const anticipation = this.foray.createAnticipation({
accrual_hash: accrual.hash,
expected_date: opportunity.CloseDate,
expected_amount: this.obfuscateAmount(amount),
conditions: [{
type: 'stage_progression',
value: this.hashValue('Closed Won'),
probability: probability / 100
}],
metadata: { next_step: ..., days_to_close: ... }
});
let action = null;
if (opportunity.StageName === 'Closed Won' && opportunity.IsClosed) {
action = this.foray.createAction({
anticipation_hash: anticipation.hash,
actual_date: opportunity.CloseDate,
actual_amount: this.obfuscateAmount(amount),
settlement_type: 'opportunity_won',
proof: { stage: ..., is_won: ..., closed_date: ... }
});
}
Mapping decisions visible in the code:
arrangement_hash, accrual_hash, anticipation_hash) rather than the QuickBooks adapter's id-based linkage (arrangement_ref, anticipation_ref). This is a direct inconsistency between the two adapters in the same repo. Both adapters reference the same hypothetical foray-sdk but assume different SDK return shapes — QB SDK methods return objects with .id; Salesforce SDK methods return objects with .hash.weighted_revenue = Amount Probability plus a risk_adjusted_value = weighted 0.85 (the 0.85 risk-adjustment factor is hard-coded in the adapter).expected_amount: null on the Anticipation, actual_amount: null on the Action. Accrual carries estimated effort/cost computed internally from priority heuristics (not from Salesforce data); the metadata field explicitly notes this: "note: Estimated values based on priority heuristics, not Salesforce data".annual_revenue and number_of_employees.entitySalt is sometimes used as a placeholder for a missing party — e.g. Quote's parties: [accountId, this.entitySalt].filter(Boolean). This is a code smell; the entitySalt is private cryptographic material, not a party identifier. (Likely a bug in the demo code.)State management:
entitySalt, lastRequestTime, config, objectTypeMap.privacyLevel is a top-level property (not nested under config), and salesforceInstanceUrl is held but appears unused beyond logging.Integration mechanics:
opportunityToForay, quoteToForay, etc. — not anchorOpportunity. The Salesforce adapter returns the constructed transaction object; anchoring happens separately via this.foray.anchorTransaction(transaction) in the batch path.opportunityToForay() returns the transaction object, and only batchProcess() calls await this.foray.anchorTransaction(transaction) afterwards. Single-object processing through opportunityToForay() directly does NOT anchor — that's left to the caller.Error handling:
validateSalesforceObject() per object type checks required fields (e.g., Opportunity requires Id and Amount; Case requires only Id).Performance characteristics:
Code structure:
ValidationError, validateConfig(), validateSalesforceObject(), then SalesforceAdapter class._log → _withRetry → _enforceRateLimit → hashIdentifier → hashValue → obfuscateAmount → opportunityToForay → quoteToForay → orderToForay → caseToForay → accountToForay → leadToForay → batchProcess → helper methods (_calculateDaysToClose, _addDays, _getPriorityWeight, _getSLAHours, _calculateSLADeadline, _calculateEstimatedEffort, _getResolutionProbability).module.exports = SalesforceAdapter;.require('./foray-sdk'), require('crypto').
Observation: the objectTypeMap lists Contract and Contact types but no handlers exist. Like the QuickBooks file, the header overstates implementation coverage.
Path: /Users/dunin7/dunin7-foray/lib/foray-client.mjs
Lines: 374
Package: dunin7-foray v3.0.0
Header (verbatim):
> "DUNIN7 FORAY Client — Append-only, hash-chained event emitter with FORAY Protocol v4.1 attestation support. > > Writes tamper-evident events to PostgreSQL. Each event's hash includes prev_hash, creating an unbroken chain. Separate database per product (foray_fieldpilot, foray_workforce, foray_forge). > > Uses per-chain mutex to serialize concurrent writes (parallel agents). > > @version 3.0.0"
Source system: None — ForayClient is the persistence adapter. The application IS the source-side adapter when using this SDK.
Data shape persisted: The foray_events PostgreSQL table:
CREATE TABLE IF NOT EXISTS foray_events (
id SERIAL PRIMARY KEY,
frame_id TEXT NOT NULL,
sequence INTEGER NOT NULL,
event_type TEXT NOT NULL,
triggered_by TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}',
prev_hash TEXT NOT NULL,
hash TEXT NOT NULL,
hash_timestamp TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (frame_id, sequence)
);
CREATE INDEX IF NOT EXISTS idx_foray_frame ON foray_events (frame_id);
CREATE INDEX IF NOT EXISTS idx_foray_type ON foray_events (event_type);
CREATE INDEX IF NOT EXISTS idx_foray_agent ON foray_events (triggered_by);
CREATE INDEX IF NOT EXISTS idx_foray_payload ON foray_events USING GIN (payload);
The shape is not the FORAY 4A protocol shape. It is frame_id + sequence + event_type + triggered_by + payload. The 4A model is layered on top via the payload JSONB, not as separate columns. An Arrangement-event would be event_type: 'arrangement.created' with the arrangement's full body in payload. The library has no Arrangement/Accrual/Anticipation/Action types as first-class concepts — those live in the application's choice of event-type strings.
FORAY v4.1 Attestation support is first-class: there is a dedicated attest(chainId, attestation) method (line 155) and four read methods specifically for attestations (getAttestations, getAttestationsFor, getAttestationsByType, getAttestationsByAttestor). The attestation event lives in the same foray_events table with event_type = 'attestation'.
Hash construction (the protocol-level integrity mechanism, lines 47-59):
function computeHash(event) {
const hashInput = {
frame_id: event.frame_id,
sequence: event.sequence,
event_type: event.event_type,
triggered_by: event.triggered_by,
payload: event.payload,
prev_hash: event.prev_hash,
timestamp: event.timestamp,
};
const content = canonicalStringify(hashInput);
return crypto.createHash('sha256').update(content).digest('hex');
}
The first event in a chain uses prev_hash = '0'.repeat(64) (line 174).
Per-chain mutex (lines 65-81): the ChainMutex class serialises writes per chainId. Concurrent callers queue behind a Promise chain. This is the integrity-preserving mechanism for parallel agents writing to the same chain.
Methods catalog (the actual deployed SDK API):
| Method | Signature | Purpose |
|---|---|---|
| constructor(opts) | opts = {product, connectionString?, host?, port?, user?, password?} | Construct client. product is required and determines the database name (foray_${product}). |
| connect() | () → Promise<this> | Ensure the foray_events table exists. Call once at startup. |
| disconnect() | () → Promise<void> | Close the connection pool. |
| emit(chainId, eventType, triggeredBy, payload) | → Promise<event> | Append one event to the chain. Acquires mutex, computes hash, commits. |
| attest(chainId, attestation) | → Promise<event> | Convenience wrapper around emit() with event_type='attestation' and triggeredBy=attestation.attestor. |
| getChain(chainId) | → Promise<event[]> | Return all events for a chain, ordered by sequence. |
| getByType(chainId, eventType) | → Promise<event[]> | Filter by event type. |
| getLatest(chainId) | → Promise<event \| null> | Most recent event in a chain. |
| count(chainId) | → Promise<number> | Event count for a chain. |
| getAttestations(chainId, {type, attestor, outcome, limit, offset}) | → Promise<event[]> | Filtered attestation lookup. |
| getAttestationsFor(chainId, subjectType, subjectId) | → Promise<event[]> | Attestations whose subject_refs[] JSONB array contains {type: subjectType, id: subjectId}. Uses Postgres @> containment. |
| getAttestationsByType(chainId, type) | → Promise<event[]> | Convenience for getAttestations({type}). |
| getAttestationsByAttestor(chainId, attestor) | → Promise<event[]> | Convenience for getAttestations({attestor}). |
| verifyChain(chainId) | → Promise<{valid: true, events: N} \| {valid: false, broken_at, reason}> | Walk the chain, recompute each hash, check sequence continuity and prev_hash linkage. |
| exportChain(chainId) | → Promise<string> | Full chain as pretty-printed JSON. |
State management:
pool — pg Pool with max: 5 connections.mutex — ChainMutex instance, holds Map<chainId, Promise>.dbName — set at construction from opts.product.Integration mechanics:
import { ForayClient } from 'dunin7-foray';.emit() inline during request handling. There is no message queue, no batch processor.Error handling:
_writeEvent (lines 161-206) wraps the insert in BEGIN / COMMIT / ROLLBACK. If the insert fails, the transaction rolls back and the error propagates.finally so an error inside the writer doesn't deadlock the chain.Performance characteristics:
BEGIN/COMMIT.Code structure:
CREATE_TABLE constant → computeHash() → ChainMutex class → ForayClient class.Write (emit, attest, _writeEvent), Read (getChain, getByType, getLatest, count), Attestation Queries, Verify (verifyChain, exportChain).pg, node:crypto, sibling ./canonical-json.mjs.dunin7-foray-validate (TypeScript predecessor)
Path: /Users/dunin7/dunin7-foray-validate/
Package name: dunin7-foray v0.1.0 ("private": true)
Status: This appears to be an earlier TypeScript implementation of the same conceptual SDK, which has since been superseded by the JavaScript v3.0.0 package at /Users/dunin7/dunin7-foray/.
Architectural design is the same: append-only, hash-chained, per-frame event log in PostgreSQL. Two-class split rather than one-class:
FORAYStore (139 lines) — owns the pg Pool, exposes init, append, getFrameEvents, getLastEvent, countFrameEvents, close.FORAYEmitter (92 lines) — wraps the store with init, emit(type, frame_id, triggered_by, payload), getFrameEvents, getEventCount, close.chain.ts (109 lines) — pure functions: computeHash, verifyEventHash, verifyChain.Differences from v3.0.0 ForayClient:
FORAYEventType) listing ~50 specific event types: frame.open, agent.invoked, architect.spec_complete, human.spec_approved, gate.pass, etc. This typed-union approach is not present in v3.0.0 (which accepts any TEXT for event_type).prev_hash is nullable (null for first event) rather than '0'.repeat(64).GENESIS sentinel for the first event when prev_hash is null.JSON.stringify(payload, Object.keys(payload).sort()) — a flat sort that doesn't recurse into nested objects. (v3.0.0's canonicalStringify recurses.)The typed event-type list is interesting source material. It enumerates a substrate-specific (FieldPilot/PartsPilot) frame lifecycle. Reproduced for reference:
v1.0 — Core lifecycle:
frame.open · agent.invoked · architect.spec_complete · narrator.brief_vN
infra.setup_complete · design.assets_complete · build.component_complete
build.complete · model.switched · strategist.checkpoint · blocker.raised
blocker.resolved · gate.pass · gate.fail · frame.delivered
v1.2 — Governance:
human.spec_approved · human.spec_rejected · human.pause_issued
human.release_issued · human.status_requested · status.document_generated
handoff.artefacts_written
v1.4 — Process discovery:
narrator.canonical_digest_vN · build.dependency_graph_validated
security.component_finding · clarification.requested · clarification.resolved
model.tier_mismatch · frame.recovery_initiated · frame.recovery_approved
frame.recovery_completed · notification.sms_sent · notification.sms_queued
notification.queue_delivered · notification.config_changed
This is the dunin7-conductor / dunin7-workforce frame lifecycle. The typed events were the design starting point; the v3.0.0 SDK then generalised by accepting any event-type string. The application defines the vocabulary; the SDK enforces append-only + hash chain regardless of what the events mean.
Path: /Users/dunin7/dunin7-docs/foray/FORAY_PLUGGABLE_PERSISTENCE_V1_0_0.md
Status: Concept document, dated 2026-02-19. No code implementation found.
This document defines a persistence-side adapter interface explicitly:
interface ForayPersistenceAdapter {
name: string;
persist(entry: ForayHashEntry): Promise<PersistResult>;
verifyChain(startId?: string, endId?: string): Promise<VerifyResult>;
retrieve(filter: ForayFilter): Promise<ForayHashEntry[]>;
isHealthy(): Promise<boolean>;
}
type ForayHashEntry = {
id: string;
forayJson: Record<string, unknown>; // The complete 4A + Attestation
hash: string; // SHA-256 of forayJson
previousHash: string | null; // Hash chain link
sequence: number; // Monotonic sequence number
createdAt: string; // ISO timestamp
};
Four destinations documented:
| Destination | Trust Level | Familiarity | Use Case | |---|---|---|---| | PostgreSQL (append-only + hash chain) | High | Very High | Default for enterprise/ERP | | NoSQL (MongoDB, DynamoDB) | High | High | Cloud-native deployments | | FORAY on Blockchain | Highest | Low | Regulated industries, max trust | | S3/Blob Storage (signed JSON files) | Medium | High | Archival, compliance exports |
Adoption phasing (per the doc):
Current state: Phase 1 only. The ForayClient in dunin7-foray v3.0.0 is the Phase 1 PostgreSQL adapter, but it does NOT implement the ForayPersistenceAdapter interface from the design doc (persist / verifyChain / retrieve / isHealthy). The actual API is emit / attest / various getters — closer to a producer SDK than the doc's pure-persistence interface.
This is a real disconnect between concept-doc intent and shipping implementation. The Phase 2 and Phase 3 adapters do not exist.
docs.html mentions a reference AI-agent adapter:
> "As a working reference implementation, FORAY provides an Audit Skill for OpenClaw, an open-source AI agent platform that can execute autonomous tasks including crypto trading on platforms like Hyperliquid and Polymarket."
Status: Source not found in /Users/dunin7/. Searched for openclaw, audit-skill paths; nothing matches. The text describes:
If this skill exists, it is hosted in the OpenClaw repository (external to DUNIN7) rather than locally checked out. Treated as a documented-but-unread adapter for completeness.
Not an adapter in the strict sense, but the canonical live consumer of ForayClient. Worth surfacing because it shows the SDK in production use.
Per FORAY_SYSTEM_KEY_ARCHITECTURE.md:
> "FORAY is the blockchain-style audit trail system for DUNIN7. It records every significant transaction and event in a tamper-evident append-only ledger. Each system (FieldPilot, PartsPilot, etc.) has its own dedicated FORAY database."
Mapping from dunin7-workforce concepts to ForayClient invocations:
foray_system_key column (VARCHAR(50)). NULL means no FORAY trail.'fieldpilot', 'partspilot') maps to a registered ForayClient instance — one per database (foray_fieldpilot, foray_partspilot).frame_id directly as the ForayClient's chainId.
Operator submits frame with project_id
→ API fetches projects.foray_system_key
→ If set: foray.for(foray_system_key).emit(frame_id, 'frame.open', triggered_by, payload)
→ If NULL: no FORAY event written
The note "Never derive foray_system_key from a name. Always set it explicitly on the project" is recorded as a past-incident lesson — silent derivation from project name corrupted audit data.
This is the deployed adapter pattern for an AI-agent system using FORAY. dunin7-workforce is the closest production parallel to what Loomworks might become.
Two SDK contracts exist in the canonical material — the hypothetical one referenced by the kaspathon adapters, and the actual deployed one.
Inferred from kaspathon adapter call sites. The file foray-sdk.js does not exist in /Users/dunin7/foray-kaspathon/ or anywhere else under /Users/dunin7/.
| Line | Call | Inferred signature |
|---|---|---|
| 24 | const ForaySDK = require('./foray-sdk'); | Module export: default class |
| 96 | new ForaySDK(config.foray \|\| {}) | Constructor takes a config object |
| 225 | this.sdk.createArrangement({source_system, transaction_type, reference_id, parties, terms}) | Sync; returns {id, ...} |
| 252 | this.sdk.createAccrual({arrangement_ref, amount, currency, formula_id, formula_description, formula_inputs, debit_account, credit_account, fiscal_period}) | Sync; returns {id, ...} |
| 271 | this.sdk.createAnticipation({arrangement_ref, expected_date, expected_amount, currency, settlement_account, conditions}) | Sync; returns {id, ...} |
| 288 | this.sdk.createAction({arrangement_ref, anticipation_ref, settlement_type, actual_amount, settlement_date, payment_method}) | Sync; returns {id, ...} |
| 300 | this.sdk.createTransaction({components: {arrangement, accrual, anticipation, action}}) | Sync; returns transaction object |
| 309 | await this.sdk.anchorToBlockchain(transaction) | Async; returns {kaspaHash, ...} |
| Line | Call | Inferred signature |
|---|---|---|
| 94 | new ForaySDK(config.forayConfig \|\| {}) | Constructor takes a config object (named forayConfig here, not foray) |
| 238 | this.foray.createArrangement({parties, asset_type, terms, effective_date, metadata}) | Sync; returns {hash, ...} — note .hash not .id |
| 258 | this.foray.createAccrual({arrangement_hash, formula_id, inputs, outputs, calculation_date, metadata}) | Sync; returns {hash, ...} |
| 260 | this.foray.getFormulaId('weighted_revenue') | Sync; returns formula identifier string |
| 278 | this.foray.createAnticipation({accrual_hash, expected_date, expected_amount, conditions, metadata}) | Sync; returns {hash, ...} |
| 298 | this.foray.createAction({anticipation_hash, actual_date, actual_amount, settlement_type, proof}) | Sync; returns {hash, ...} |
| 668 | this.foray.createAction({arrangement_hash, ...}) | Variant — Action can ref Arrangement directly (Lead conversion) |
| 722 | await this.foray.anchorTransaction(transaction) | Async; method name anchorTransaction not anchorToBlockchain |
Inconsistencies between the two adapters' SDK assumptions:
.id vs .hash return: QuickBooks expects .id; Salesforce expects .hash. Either both API shapes exist, or one adapter is wrong about the SDK shape.createAction reference field: QB uses arrangement_ref/anticipation_ref; Salesforce uses arrangement_hash/anticipation_hash/accrual_hash.createTransaction vs anchorTransaction: QB builds an intermediate createTransaction({components:...}) then anchorToBlockchain(transaction). Salesforce skips the wrapping step and calls anchorTransaction(transaction) directly.config.foray; Salesforce takes config.forayConfig.getFormulaId: referenced only by Salesforce. QB hardcodes formula_id strings.Inferred SDK signature (best guess):
class ForaySDK {
constructor(config) { /* connection / chain config */ }
createArrangement({source_system?, transaction_type?, asset_type?, reference_id?, parties, terms, effective_date?, metadata?}) → {id, hash, ...}
createAccrual({arrangement_ref?, arrangement_hash?, amount?, formula_id, formula_description?, formula_inputs?, inputs?, outputs?, debit_account?, credit_account?, fiscal_period?, calculation_date?, metadata?}) → {id, hash, ...}
createAnticipation({arrangement_ref?, accrual_ref?, accrual_hash?, expected_date, expected_amount, currency?, settlement_account?, conditions?, metadata?}) → {id, hash, ...}
createAction({arrangement_ref?, arrangement_hash?, anticipation_ref?, anticipation_hash?, accrual_hash?, settlement_type, actual_amount?, actual_date?, settlement_date?, payment_method?, proof?}) → {id, hash, ...}
createTransaction({components: {arrangement, accrual, anticipation, action}}) → transaction
getFormulaId(name) → string
anchorToBlockchain(transaction) async → {kaspaHash, ...}
anchorTransaction(transaction) async → {kaspaHash?, ...}
}
This is inferred speculation. The actual foray-sdk.js file is not in the repository. The kaspathon adapters were written against a target SDK that either never shipped publicly or shipped in a different shape than the demos assumed.
dunin7-foray v3.0.0Already fully catalogued in §2.3. The contract is fundamentally different:
createArrangement / createAccrual / createAnticipation / createAction methods. The 4A model is layered via event_type strings in the application's choice.anchorToBlockchain. Persistence is PostgreSQL only; blockchain anchoring is deferred to a future Pluggable Persistence Phase 3.emit(chainId, eventType, triggeredBy, payload) — generic event emission with hash chaining.attest(chainId, attestation) and four read methods, but the attestation is just another event type with a structured payload.Method count: 14 methods total (constructor, connect, disconnect, emit, attest, _writeEvent (internal), getChain, getByType, getLatest, count, getAttestations, getAttestationsFor, getAttestationsByType, getAttestationsByAttestor, verifyChain, exportChain).
The descriptive material at foray.dunin7.com (integration-guide.html, docs.html, specification.html) does NOT document either SDK contract:
anchorToBlockchain (or equivalent) is not.There is no published SDK reference. The two adapter source files are the only place SDK calls are visible, and they disagree with each other.
The translation boundary sits where the source-system-native object is passed into the adapter's anchor method:
Input: Salesforce REST API JSON (Opportunity object with native fields)
Output: A constructed FORAY transaction object via SDK calls
What goes in (Salesforce Opportunity, raw):
{
"Id": "0061T00000abc123",
"AccountId": "0011T00000xyz789",
"OwnerId": "00558000abc",
"StageName": "Proposal",
"Amount": 50000,
"Probability": 60,
"CurrencyIsoCode": "USD",
"CloseDate": "2026-03-15",
"CreatedDate": "2026-01-10T...",
"ForecastCategoryName": "Pipeline",
"IsClosed": false,
"IsWon": false
}
What comes out (after Salesforce adapter, before SDK):
{
arrangement: {
parties: ['<hashed-account-id>', '<hashed-owner-id>'],
asset_type: 'sales-opportunity',
terms: { stage: '<hashed-stage>', type: '<hashed-type>', lead_source: '<hashed-source>', created_date: '2026-01-10T...' },
effective_date: '2026-01-10T...',
metadata: { salesforce_id: '<hashed-id>', object_type: 'Opportunity' }
},
accrual: {
arrangement_hash: '<arrangement.hash>',
formula_id: '<from sdk.getFormulaId("weighted_revenue")>',
inputs: { amount: 50000, probability: 60, currency: '<hashed-USD>' },
outputs: { expected_revenue: 30000, risk_adjusted_value: 25500 }, // rounded per privacy level
calculation_date: '2026-05-24T...',
metadata: { stage: '<hashed>', forecast_category: '<hashed>' }
},
anticipation: {
accrual_hash: '<accrual.hash>',
expected_date: '2026-03-15',
expected_amount: 50000,
conditions: [{ type: 'stage_progression', value: '<hashed-Closed Won>', probability: 0.6 }],
metadata: { next_step: '<hashed>', days_to_close: 45 }
},
action: null // null because IsClosed=false; only created when IsClosed && StageName==='Closed Won'
}
hashIdentifier(id, type) which SHA-256-hashes ${entitySalt}:${type}:${id}. The full 64-char hex is stored in the QuickBooks adapter; the Salesforce adapter does the same; but the Salesforce adapter's hashValue() for non-identifying values truncates to 16 chars.Description field on Opportunity is dropped (privacy + structural mismatch). QuickBooks' Line[] is summarised to a count, not enumerated.expected_revenue = amount probability/100 and risk_adjusted_value = expected 0.85. QuickBooks adapter computes subtotal = TotalAmt - TxnTaxDetail?.TotalTax. These derived fields don't exist in the source; the adapter constructs them.Lost:
Description, PrivateNote body (only presence noted, not content).SystemModstamp, LastViewedDate, etc.).Added:
formula_id and formula_description — these are FORAY-side conceptual labels that don't exist in the source.fiscal_period (computed from transaction date, not stored in QB).days_to_close (computed; not in Salesforce).entitySalt per-adapter — adds cross-transaction unlinkability that the source has no concept of.estimated_effort_hours and estimated_cost are internal heuristics explicitly noted as not from Salesforce.
| Field type | Source | Notes |
|---|---|---|
| Id | Required from source | Validation throws if missing |
| TotalAmt / Amount | Required from source for monetary types | Validation throws if missing |
| transaction_type / asset_type | Static configuration in the adapter | objectTypeMap in Salesforce; literal strings in QB |
| privacy_level | Configured at adapter construction | Defaults to 'standard' |
| entitySalt | Optional config; random 32 bytes if not provided | Cryptographic salt |
| source_system | Hardcoded in QB adapter ('quickbooks') | Identifies origin |
| formula_id | Hardcoded string per anchor method | 'invoice_total', 'bill_total', etc. |
The translation boundary is different — the application has already done 4A construction, and the SDK's job is to persist + hash:
Input: An application-constructed event payload (any JSON shape)
Output: A PostgreSQL row in foray_events with a chained hash
There is no 4A-specific translation in the SDK. The application chooses event-type strings and payload shapes. The SDK enforces:
frame_id (DB UNIQUE constraint).
The Pluggable Persistence design doc envisioned a stricter contract — a ForayHashEntry with explicit forayJson + hash + previousHash — but the live SDK is looser. The application can emit('arbitrary.event.type', 'system', {anything: 'goes'}) and the SDK will chain it.
sdk.anchorToBlockchain fails after retries, the per-method call throws. The application must catch and decide what to do.There is no queue, no journal, no event log on the adapter side. Replay would require the application to re-extract from the source system and re-call the adapter.
foray_events table is itself the persistence layer. Every event is committed via BEGIN/COMMIT. No in-memory queue.emit() calls reject. The application must handle the error — typically by retrying once the DB is back, or by accepting that the event is not recorded.The architectural posture is: events are persisted synchronously or they fail synchronously. There is no eventual-consistency fallback. This is consistent with the FORAY "tamper-evident" claim — there is no buffer where events could be tampered with before persistence.
The design doc envisioned fan-out persistence with admin-configurable retry:
type ForayPipelineConfig = {
destinations: { postgres, nosql, blockchain, s3 };
requireAll: boolean; // true = all enabled must succeed; false = best-effort
retryPolicy: { maxRetries, backoffMs };
};
In the imagined Phase 2 architecture, an event would be fanned out to all enabled destinations in parallel. The retry policy would apply per destination. Best-effort mode would let a slow destination not block others.
None of this is implemented today. The live SDK is single-destination PostgreSQL with no retry policy.
Constructor config shape (QuickBooks):
new QuickBooksForayAdapter({
quickbooks: qboClient, // Required: a QBO client instance
foray: {/* SDK config */}, // Required: SDK constructor arg
privacyLevel: 'standard', // 'minimal' | 'standard' | 'high' | 'defense'
entitySalt: '<hex>', // Optional: 32-byte hex; random if absent
retryAttempts: 3,
retryDelayMs: 1000,
enableLogging: true,
minRequestInterval: 100
});
Credentials needed:
qboClient instance — not the adapter's concern directly)config.foray — opaque to the adapter)Deployment: Library, imported by the application. No standalone deployment.
Lifecycle: No start/stop methods. Construct, call anchor methods, discard.
new ForayClient({
product: 'fieldpilot', // Required: determines database name foray_${product}
connectionString: 'pg://...', // OR host/port/user/password individually
host: 'localhost', // Default: PG_HOST env or 'localhost'
port: 5432, // Default: PG_PORT env or 5432
user: 'postgres', // Default: PG_USER env or 'postgres'
password: '...' // Default: PG_PASSWORD env
});
Environment variable conventions:
PG_HOST, PG_PORT, PG_USER, PG_PASSWORD (workforce TECHNICAL_REFERENCE notes PG_USER must be explicitly set to dunin7 — the SDK defaults to postgres if absent).Credentials needed:
Deployment: Library, imported by the application. The PostgreSQL database itself must be provisioned separately (one database per product, with the foray_events table created on first connect()).
Lifecycle:
connect() — once at app startup; creates table if missing.emit(...) / attest(...) / queries — during runtime.disconnect() — at app shutdown; closes the pg Pool.Per-system provisioning (from workforce architecture doc):
CREATE DATABASE foray_{key};foray_events).foray_system_key on the relevant projects.
The descriptive guide does not specify a single deployment model. It implies the adapter runs somewhere between the source system and FORAY — could be a container, a service, a library. Per integration-guide.html:
> "Phase 1: Extract — Connect to source system API (REST, SOAP, BAPI, etc.)"
implies the adapter has network access to the source system. The integration patterns assume API access; no on-premise database connection details are documented.
integration-guide.html is the primary integration document. Its structure:
1. Why FORAY + ERP — value proposition
2. Integration Pattern — five-step Extract/Transform/Hash/Anchor/Store
3. SAP S/4HANA — per-system: audit gap, FORAY mapping table
4. Oracle EBS / Cloud — per-system: audit gap, mapping
5. QuickBooks — per-system: invoice lifecycle mapping, link to .js
6. Salesforce — per-system: object lifecycle table, cross-system reconciliation
7. NetSuite — per-system: transaction mapping table
8. Adapter Architecture — three-phase pattern
9. Privacy Levels — Standard / High / Defense-grade
10. Batch vs. Real-Time Anchoring — strategy table
docs.html is the protocol-and-integration overview. Its adapter-relevant sections:
Each per-ERP section follows a similar shape:
The shape is descriptive, not prescriptive. It does not specify endpoint URLs, message formats, or wire protocols.
No templates or scaffolds exist in the repository. The two source adapters (QuickBooks, Salesforce) are the only working code examples. They diverge enough from each other that they cannot serve as a single template.
The Pluggable Persistence doc proposes a ForayPersistenceAdapter interface (for the destination-side adapter), but this is concept-level, not a code scaffold.
From RFC-0001.md (in foray-protocol-db, read in the challenge deep-read):
> "Connector implementations. Build a FORAY-compliant connector for any source system. The connector specification defines the submission schema. Any system that can produce a valid FORAY submission is a compliant source. There is no certification required to build. Certification is available for those who want it."
The compliance bar is produce a valid FORAY submission per the schema. There is no formal SDK-conformance test, no certification suite. Adapter compliance is structural — does your adapter produce valid 4A JSON per the schema reference. The schema reference is in docs/DUNIN7_FORAY_Schema_Reference.md (covered in the protocol deep-read).
The single most important finding from this deep-read. "Adapter" in FORAY material refers to:
The two adapters' lifecycles, contracts, and concerns are different. A Loomworks integration design that conflates them would design the wrong thing. The decision space is:
dunin7-foray is used, the choice is already made: PostgreSQL.
Reproduced verbatim from docs.html lines 1118-1148:
| FORAY Component | What It Captures for AI Agents | Example | |---|---|---| | Arrangement | The agent's mandate — what it was authorized to do, by whom, under what constraints | Agent authorized to approve purchase orders under \$50K, referencing model version hash and system prompt hash | | Accrual | The agent's reasoning — what information was analyzed, what it determined | Agent evaluates three vendor quotes, determines Vendor B is \$2,400 below market rate | | Anticipation | The agent's intent — what action it planned, with what expected outcome | Agent plans to approve \$47,200 PO to Vendor B; expected outcome: delivery within SLA | | Action | The agent's execution — what it actually did, the result, variance from intent | PO approved and submitted at 14:32:07 UTC; confirmation reference PO-2026-04471 |
This is the mapping a Loomworks-as-FORAY-anchored-system would use. Every Companion-mediated assertion commit decomposes:
The model version hash + system prompt hash bit is particularly relevant — Loomworks already has a system_prompt field on its specialists. A FORAY arrangement for a Loomworks engagement would hash these.
In both kaspathon adapters, privacy obfuscation happens inside the adapter (_obfuscateAmount, _hashIdentifier). The SDK never sees plaintext values. This pattern means:
The dunin7-foray SDK has no built-in privacy obfuscation — values are persisted as-is in JSONB. This is consistent with the adapter-handles-privacy convention. A Loomworks adapter would need to decide its privacy posture before calling the SDK.
In Salesforce's Quote/Order handlers (e.g., line 350):
parties: [accountId, this.entitySalt].filter(Boolean)
The entitySalt is being used as a stand-in for "this adapter's organisation." This is concerning — entitySalt is meant to be a cryptographic salt for hashing identifiers; using it as a party id directly leaks cryptographic material into the FORAY transaction. This appears to be a demo-code shortcut where the adapter's owning organisation is fixed and the salt happens to be unique enough. A Loomworks production adapter should not replicate this pattern.
kasperatcoder or named anchor serviceAcross all source and documentation read, no service called the "FORAY anchor service" exists as a deployable HTTP API. The Kaspa blockchain is the anchor service in the kaspathon adapters' model; PostgreSQL is the anchor service in the dunin7-foray model. The Pluggable Persistence design doc proposes additional anchors (NoSQL, S3, on-chain hash anchoring) but none are implemented.
Implication for Loomworks: "integrating with the FORAY anchor service" is ambiguous wording. Concrete choices are:
dunin7-foray ForayClient (live, deployed).The pragmatic Loomworks choice today is the first option.
Considered together: (a) the disclaimer block calls them demonstration-only; (b) they reference an absent SDK; (c) the two adapters disagree on SDK shape; (d) batch processing is sequential; (e) no error replay; (f) entitySalt mis-used. **These adapters were written to describe what a FORAY adapter could look like, not to be deployable.** They populate the integration-guide.html sections with link-out references but cannot themselves be installed and run.
The live deployed FORAY usage is dunin7-foray v3.0.0 + dunin7-workforce direct calls. There is no production source-side adapter in the kaspathon style.
The SDK's emit(chainId, eventType, triggeredBy, payload) accepts any TEXT for eventType. The application is the source of vocabulary — there is no SDK-side validation. This means:
assertion.committed, companion.responded).
The ChainMutex (lines 65-81 of foray-client.mjs) serialises ALL writes to a given chain. If multiple agents in Loomworks write to the same engagement's chain in parallel, they queue. Per-chain throughput is therefore single-threaded.
For Loomworks specifically: this means parallel Companion processes operating on the same engagement would queue on writes. This is necessary for hash chain integrity but is a throughput consideration.
Neither the kaspathon adapters nor the live SDK specifies a delivery guarantee. The live SDK is exactly-once if the transaction commits, and the application sees the success result; if the application crashes after the DB commit but before processing the result, the event is durably persisted but the application doesn't know it. There is no idempotency token or replay-detection mechanism.
For Loomworks: an idempotent commit would have to be the application's concern — e.g., a memory_event has a content_hash that could double as an idempotency check.
The dunin7-foray-validate package (the TypeScript predecessor) enforces a discriminated union of event types at compile time. v3.0.0 dropped this constraint. The trade-off:
The v3.0.0 choice was deliberate (the SDK is now generic across products), but a more disciplined application would re-establish a typed vocabulary at its own layer.
Neither SDK emits Prometheus / OTel / structured logs at observability frequencies. The application would have to wrap SDK calls to capture latency / failure rate. The enableLogging flag on kaspathon adapters just routes to console.log with timestamps; no structured logging.
The protocol deep-read §5 ("The FORAY anchor service — operational interface") concluded:
> "There is enough material in the local repository to know the conceptual shape: the submitting system constructs a FORAY envelope with a declared persistence layer, submits it to a layer-specific service (probably via the absent foray-sdk.js), receives a blockchain_anchor block..."
That conclusion was inferred from the kaspathon adapter call sites. It is partially wrong — the actual deployed SDK (dunin7-foray v3.0.0) does NOT match that shape. The deployed SDK:
anchorToBlockchain method.createArrangement/Accrual/Anticipation/Action methods.blockchain_anchor block — there is no blockchain integration at all.
The protocol deep-read's conclusion accurately describes the kaspathon hypothetical model, but the live deployed model is the ForayClient-PostgreSQL chain. The two are different architectures.
The challenge deep-read §5 said:
> "The challenge site does not document a FORAY anchor-service wire interface... the persistence layer (Kaspa or otherwise) is referenced as a property of 'real' submissions but the demo does not perform anchoring."
This is correct, but incomplete. There is a live persistence layer — PostgreSQL via dunin7-foray — that is not what the challenge site documents but is what actually runs in dunin7-workforce. The challenge site's framing emphasises Kaspa; the deployed reality is PostgreSQL. Both are persistence layers in the Pluggable Persistence sense; only one (PostgreSQL) has a working implementation.
The protocol and challenge deep-reads both wrote about "adapters" without distinguishing source-side from persistence-side. The kaspathon adapters were referenced as if they showed how Loomworks would integrate. This deep-read finds the kaspathon adapters are demo-grade and reference an absent SDK; the production pattern is dunin7-foray ForayClient called directly by application code. A Loomworks integration would more closely resemble dunin7-workforce's pattern (which is library-call-from-application) than the kaspathon ERP adapter pattern.
This is the most important correction to surface for Claude.ai's next round of substrate-review work.
| Topic | What's missing | Where it likely is |
|---|---|---|
| The hypothetical foray-sdk.js source | Absent from kaspathon repo and the rest of the filesystem | Was never implemented in the shape the adapters assume; or lives in DUNIN7-internal storage not visible here |
| OpenClaw Trading Audit Skill source | Not in /Users/dunin7/ | Lives in the OpenClaw repository (external) |
| A working Kaspa anchor implementation | Not in any reviewed source | Would need to be built per Pluggable Persistence Phase 3 |
| A standardised ForayPersistenceAdapter implementation | The interface is in the design doc; ForayClient doesn't conform to it | Phase 2/3 of Pluggable Persistence not yet built |
| End-to-end flow demonstration (source → SDK → anchor → verify) | The kaspathon repo's foray-api-server.js demonstrates source → LLM → 4A JSON, but stops there | The dunin7-workforce app shows application → ForayClient → PostgreSQL, but the kaspathon-style adapter layer between source ERP and 4A is missing |
| SDK reference documentation | Neither SDK has published reference docs | The shape has to be inferred from source code (which I did in §3) |
| Performance benchmarks | Adapter rate limit and SDK mutex behaviour suggest single-chain throughput is low single-digits/second, but no published benchmarks | None found |
| Adapter certification / compliance test suite | RFC-0001 mentions certification is "available for those who want it" but no test suite is referenced | Not in any reviewed repo |
| NetSuite, SAP, Oracle adapters as source code | Only QuickBooks and Salesforce have source adapters | NetSuite/SAP/Oracle are described in integration-guide.html but no code exists |
Three reads now compose:
dunin7-foray ForayClient, AI-agent 4A mapping from docs.htmlWhat this means for the substrate review work that follows:
ForayClient.emit(engagement_id, event_type, actor, payload) called from inside the Loomworks engine at significant lifecycle events.event_type: 'arrangement.engagement_session' with the 4A Arrangement payload inside), not via SDK method names.*_hash columns); this would extend to party identifiers in FORAY payloads.foray.attest(). The Companion's classifier/responder/committer would each emit attestation events chained to the same engagement.End of adapter deep-read.