@shutter-network/concorde/decisions
The Decisions component owns the one global log of Decisions. A Decision is a Statement the shared agent has committed to in public: signed with its key, numbered from 1, kept forever, and readable by every User rather than addressed to one.
createDecisions makes one. Decisions is what comes back, and its programmatic API publishes into the log and reads it back. DecisionRecord is what every surface answers with, and jws is the field that matters: the artifact is the Decision, and the other three fields can be read back out of it by anybody holding the public key.
Construct Signatures first. Every Decision is signed, so there is no degraded mode in which rows arrive without artifacts. Nothing else is taken: the two Public reads run behind the Public server's own hook, so a deployment with no Auth registered on that server refuses both on every request.
Publishing notifies nobody. It emits no Signal and wakes no Handler, so a User discovers a Decision by polling, and the largest seq they hold is the whole resume mechanism.
The table is on @shutter-network/concorde/decisions/schema and nowhere else. It references no other component's table, so that subpath can be listed on its own.
Example
A Gateway with Decisions, and a Statement committed to from the Operator's own code.
import { createPrivateKey } from "node:crypto";
import { readFileSync } from "node:fs";
import { createGateway } from "@shutter-network/concorde/gateway";
import { createDecisions } from "@shutter-network/concorde/decisions";
import { createPasswordAuth } from "@shutter-network/concorde/password-auth";
import { createPiRuntime } from "@shutter-network/concorde/pi";
import { createSignatures } from "@shutter-network/concorde/signatures";
import { createUsers } from "@shutter-network/concorde/users";
const gateway = createGateway({
databaseUrl: process.env.DATABASE_URL ?? "",
runtime: createPiRuntime({ image: "my-agent:1" }),
// Not loopback: the agent reaches this server from a container of its own.
agentListen: { host: "0.0.0.0", port: 8081 },
publicListen: { host: "0.0.0.0", port: 8080 },
extend: ({ db, agentServer, publicServer }) => {
const users = createUsers({ db, agentServer, publicServer });
const signatures = createSignatures({
signingKey: createPrivateKey(readFileSync("./signing-key.pem")),
agentServer,
publicServer,
});
return {
users,
// Some scheme has to be registered, or both reads refuse every request.
passwordAuth: createPasswordAuth({ db, users, publicServer, tokenTtl: 86_400_000 }),
signatures,
decisions: createDecisions({ db, signatures, agentServer, publicServer }),
};
},
handlers: () => ({}),
});
await gateway.start();
// The artifact is in hand before the transaction commits.
const { db, decisions } = gateway.components;
const published = await db.tx((tx) => decisions.publish(tx, "shipping on Friday"));
console.log(published.seq, published.jws);Type Aliases
DecisionRecord
type DecisionRecord = {
readonly createdAt: string;
readonly jws: string;
readonly seq: number;
readonly statement: string;
};A Decision as every surface answers with it: the publish response, both reads, and history.
The artifact is the Decision. Anybody holding the public key reads the other three fields back out of jws, which is what makes handing one string to a third party worth doing.
createdAt is ISO 8601, JSON having no date, and is the same string the artifact's payload carries rather than a re-rendering of it.
Properties
createdAt
readonly createdAt: string;jws
readonly jws: string;seq
readonly seq: number;statement
readonly statement: string;Decisions
type Decisions = Component & {
history: (options?: {
readonly after?: number;
readonly before?: number;
readonly limit?: number;
}) => Promise<DecisionRecord[]>;
publish: <TSchema>(tx: Handle<TSchema>, statement: string) => Promise<DecisionRecord>;
start: () => Promise<void>;
stop: () => Promise<void>;
};The Decision log as a Component. Its programmatic API is two methods: a publish that joins the caller's transaction, and a read of the whole log that needs neither a Token nor a route.
Every other capability is a route this component registered itself, and no route plugin is exported. A Signal Handler therefore commits to something and builds the next Prompt out of what is already committed to, without going near HTTP.
There is no parameter for the artifact anywhere, so no caller's bytes reach the jws column, and neither method takes a User id, the log having no owner and nothing to scope by.
Publishing notifies nothing. No Signal is emitted and no Handler wakes, so a Decision published during a Run cannot queue work for the Run that published it.
start and stop do nothing. A Decision is a committed row and an artifact somebody may already hold, and both outlive this process.
Type Declaration
history()
history(options?: {
readonly after?: number;
readonly before?: number;
readonly limit?: number;
}): Promise<DecisionRecord[]>;Reads the log, ascending by seq, so a Handler can see everything already committed to.
Nothing scopes it: every reader sees the same sequence, and options is what bounds the answer. Asking for everything means { after: 0, limit: <large> } rather than omitting the argument, which answers the newest page instead.
A read, so it takes no transaction and cannot see the caller's own uncommitted write. limit takes the routes' default when omitted and is not capped here, a cap being there to bound a response body.
Parameters
options?
after?
number
before?
number
limit?
number
Returns
Promise<DecisionRecord[]>
publish()
publish<TSchema>(tx: Handle<TSchema>, statement: string): Promise<DecisionRecord>;Publishes a Decision inside the transaction tx belongs to, and answers with the record.
Takes the caller's transaction rather than opening one, so committing to something and recording why cannot come apart: a rollback loses both. Ambient enlistment is not available, because a second handle takes its own connection and its writes would survive that rollback.
statement is the only other argument. The number, the timestamp and the artifact belong to the write path, and the record comes back from here because a read cannot see the caller's own uncommitted write.
A publish that rolls back burns its number, the sequence not being transactional. Gaps in the log are expected and mean nothing.
Type Parameters
TSchema
TSchema extends Record<string, unknown>
Parameters
tx
Handle<TSchema>
statement
string
Returns
Promise<DecisionRecord>
start()
start(): Promise<void>;Returns
Promise<void>
stop()
stop(): Promise<void>;Returns
Promise<void>
DecisionsOptions
type DecisionsOptions = {
readonly agentServer: {
readonly fastify: FastifyInstance;
};
readonly db: Db;
readonly publicServer: {
readonly fastify: FastifyInstance;
readonly requireUser: preHandlerAsyncHookHandler;
};
readonly signatures: Signatures;
};Properties
agentServer
readonly agentServer: {
readonly fastify: FastifyInstance;
};Where the agent publishes and reads, at /decisions.
Structural: anything carrying a Fastify instance satisfies it.
fastify
readonly fastify: FastifyInstance;db
readonly db: Db;publicServer
readonly publicServer: {
readonly fastify: FastifyInstance;
readonly requireUser: preHandlerAsyncHookHandler;
};Where any authenticated User reads the log, at /decisions, and the schemes that read accepts.
A log no User can read is not public, and a commitment that is not public is not a commitment, so there is no assembly of this component that omits it.
requireUser is the server's own composed hook, taken as one route option, so this component holds no credential and authenticates nobody. It is not a schema-level dependency either: nothing here references a User, so this component's /schema subpath may be listed without the table of Users.
Structural, on the same terms as agentServer: anything carrying a Fastify instance and a requireUser satisfies it, which is what serverComponent answers with.
fastify
readonly fastify: FastifyInstance;requireUser
readonly requireUser: preHandlerAsyncHookHandler;signatures
readonly signatures: Signatures;Where every Decision is signed, which is why this component holds no key of its own.
Build it first. Signing happens through this object in process and never as an HTTP request to the Signatures routes, so a publish inside a transaction never leaves the process.
Functions
createDecisions()
function createDecisions(options: DecisionsOptions): Decisions;Builds Decisions and registers its two route groups at /decisions on both servers.
Nothing here connects, listens or applies DDL.