Skip to content

@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.

ts
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

ts
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
ts
readonly createdAt: string;
jws
ts
readonly jws: string;
seq
ts
readonly seq: number;
statement
ts
readonly statement: string;

Decisions

ts
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()
ts
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()
ts
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()
ts
start(): Promise<void>;
Returns

Promise<void>

stop()
ts
stop(): Promise<void>;
Returns

Promise<void>


DecisionsOptions

ts
type DecisionsOptions = {
  readonly agentServer: {
    readonly fastify: FastifyInstance;
  };
  readonly db: Db;
  readonly publicServer: {
    readonly fastify: FastifyInstance;
    readonly requireUser: preHandlerAsyncHookHandler;
  };
  readonly signatures: Signatures;
};

Properties

agentServer
ts
readonly agentServer: {
  readonly fastify: FastifyInstance;
};

Where the agent publishes and reads, at /decisions.

Structural: anything carrying a Fastify instance satisfies it.

fastify
ts
readonly fastify: FastifyInstance;
db
ts
readonly db: Db;
publicServer
ts
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
ts
readonly fastify: FastifyInstance;
requireUser
ts
readonly requireUser: preHandlerAsyncHookHandler;
signatures
ts
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()

ts
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.

Parameters

options

DecisionsOptions

Returns

Decisions