@arcforgelabs/flow 2026.10.53 → 2026.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +1 -0
  2. package/SECURITY.md +7 -4
  3. package/npm-shrinkwrap.json +442 -4
  4. package/package.json +8 -3
  5. package/server/agents/auth-worker.js +7 -0
  6. package/server/agents/auth.js +50 -0
  7. package/server/agents/gate.js +138 -0
  8. package/server/agents/grants.js +51 -0
  9. package/server/agents/reads.js +47 -0
  10. package/server/agents/server.js +74 -0
  11. package/server/agents/settings.js +24 -0
  12. package/server/app.js +9 -0
  13. package/server/core/ledger.js +90 -16
  14. package/server/core/line-mapping.js +2 -2
  15. package/server/core/read-before-act.js +2 -2
  16. package/server/core/reconcile.js +15 -1
  17. package/server/core/sending.js +20 -2
  18. package/server/db/migrations/0025_powerful_leopardon.sql +1 -0
  19. package/server/db/migrations/0026_bitter_sersi.sql +38 -0
  20. package/server/db/migrations/meta/0025_snapshot.json +4709 -0
  21. package/server/db/migrations/meta/0026_snapshot.json +4926 -0
  22. package/server/db/migrations/meta/_journal.json +14 -0
  23. package/server/db/schema.js +38 -1
  24. package/server/index.js +9 -1
  25. package/server/integrations/port.js +1 -0
  26. package/server/integrations/seazona/history/backfill.js +3 -1
  27. package/server/integrations/seazona/history/index.js +4 -7
  28. package/server/integrations/seazona/index.js +5 -1
  29. package/server/integrations/seazona/lanes.js +44 -27
  30. package/server/integrations/seazona/ledger.js +17 -6
  31. package/server/integrations/seazona/reconcile.js +12 -0
  32. package/server/rate-limit.js +2 -1
  33. package/server/ui/app.js +74 -62
  34. package/server/ui/flow.css +36 -5
  35. package/server/ui/invoicing.js +34 -7
  36. package/server/ui/mobile.js +17 -0
  37. package/server/ui/routes.js +1 -1
  38. package/server/ui/sync-status.js +67 -0
@@ -100,12 +100,13 @@ async function normaliseLines(tx, organisationId, lines) {
100
100
  priceItemId = item?.archivedAt ? null : (item?.id ?? null);
101
101
  description = (description ?? item?.name ?? '').trim();
102
102
  unitAmountCents ??= item?.unitAmountCents;
103
- taxCode ??= item?.taxCode ?? 'GST';
103
+ taxCode ??= item?.taxCode ?? (line.taxCode === null ? null : 'GST');
104
104
  const quantity = line.quantity ?? 1;
105
105
  if (!description) throw new FlowError('invalid', `line ${index + 1}: description is required`);
106
106
  let totals;
107
107
  try {
108
- totals = lineTotals({ quantity, unitAmountCents, taxCode });
108
+ totals = lineTotals({ quantity, unitAmountCents, taxCode: taxCode ?? 'GST_FREE' });
109
+ if (taxCode === null) totals.taxCents = null;
109
110
  } catch (error) {
110
111
  throw new FlowError('invalid', `line ${index + 1}: ${error.message}`);
111
112
  }
@@ -128,6 +129,48 @@ function fingerprint(lines) {
128
129
  return createHash('sha256').update(JSON.stringify(keys)).digest('hex');
129
130
  }
130
131
 
132
+ const SOURCE_TOTAL_FIELDS = ['subtotalCents', 'taxCents', 'totalCents'];
133
+ const REVIEW_SOURCE_TOTALS = 'job.source_totals';
134
+
135
+ function readSourceTotals(raw) {
136
+ if (raw == null) return null;
137
+ const totals = {};
138
+ for (const field of SOURCE_TOTAL_FIELDS) {
139
+ if (raw[field] == null) continue;
140
+ if (!Number.isSafeInteger(raw[field])) throw new FlowError('invalid', 'source ' + field + ' must be whole cents');
141
+ totals[field] = raw[field];
142
+ }
143
+ return Object.keys(totals).length ? totals : null;
144
+ }
145
+
146
+ // A source invoice total is evidence, not permission to allocate its tax.
147
+ export function sourceBillingProblems(obligation) {
148
+ const reasons = [];
149
+ const lines = obligation.lines ?? [];
150
+ if (lines.some((line) => line.taxCode === null)) reasons.push('a source line has unknown tax treatment');
151
+ const calculated = invoiceTotals(lines);
152
+ if (SOURCE_TOTAL_FIELDS.some((field) => obligation.sourceTotals?.[field] != null && obligation.sourceTotals[field] !== calculated[field])) {
153
+ reasons.push('the source invoice totals differ from the reconstructed lines');
154
+ }
155
+ return reasons;
156
+ }
157
+
158
+ async function recordSourceReview(tx, row, actor) {
159
+ const reasons = sourceBillingProblems(row);
160
+ if (reasons.length) {
161
+ await openReview(tx, row.organisationId, {
162
+ kind: REVIEW_SOURCE_TOTALS, entity: 'obligation', flowId: row.id,
163
+ systems: [row.sourceSystem, 'flow'], moneyAtRiskCents: Math.abs(row.totalCents),
164
+ evidence: { reasons, sourceTotals: row.sourceTotals, reconstructed: invoiceTotals(row.lines),
165
+ unknownTaxLines: row.lines.flatMap((line, index) => line.taxCode === null ? [index + 1] : []),
166
+ sourceJobRef: row.sourceJobRef, invoiceId: row.invoiceId },
167
+ });
168
+ } else {
169
+ await resolveReviewFor(tx, row.organisationId, { kind: REVIEW_SOURCE_TOTALS, entity: 'obligation', flowId: row.id, resolution: 'source_totals_match', actor });
170
+ }
171
+ return row;
172
+ }
173
+
131
174
  // Records what the work system reports about a billable job. The first report
132
175
  // makes the obligation; a later report with other lines or totals changes it.
133
176
  // If the job changes after Flow made its invoice, the obligation goes to review.
@@ -137,17 +180,20 @@ export async function observeJob(db, organisationId, job) {
137
180
  const seenAt = job.observedAt ? new Date(job.observedAt) : new Date();
138
181
  return db.transaction(async (tx) => {
139
182
  const lines = await normaliseLines(tx, organisationId, job.lines);
183
+ const sourceTotals = readSourceTotals(job.sourceTotals);
184
+ const totals = { ...invoiceTotals(lines), ...sourceTotals };
185
+ if (sourceTotals?.totalCents == null) totals.totalCents = totals.subtotalCents + totals.taxCents;
140
186
  const facts = {
141
187
  sourceInvoiceRef: job.sourceInvoiceRef ?? null,
142
188
  caseRef: job.caseRef ?? null,
143
189
  contactId: job.contactId ?? null,
144
190
  practitioner: job.practitioner ?? null,
145
- shippedOn: job.shippedOn ?? null,
146
191
  jobDate: job.jobDate ?? null,
147
192
  sourceStatus: job.sourceStatus ?? null,
148
193
  lines,
149
194
  lineFingerprint: fingerprint(lines),
150
- ...invoiceTotals(lines),
195
+ sourceTotals,
196
+ ...totals,
151
197
  mappingComplete: lines.every((l) => l.priceItemId || l.accountCode),
152
198
  };
153
199
  const [existing] = await tx.select().from(billingObligations)
@@ -158,26 +204,36 @@ export async function observeJob(db, organisationId, job) {
158
204
  ))
159
205
  .for('update');
160
206
 
207
+ // A work system without a shipped date (Seazona) says only that the job
208
+ // is shipped: the day Flow sees its status move to a shipped one is its
209
+ // shipped day. A job Flow first sees already shipped (a first sync, or a
210
+ // job shipped before Flow kept this day) stays without one.
211
+ const statusMoved = existing && String(existing.sourceStatus ?? '') !== String(facts.sourceStatus ?? '');
212
+ facts.shippedOn = job.shippedOn ?? existing?.shippedOn
213
+ ?? (job.shipped && statusMoved ? localDay(seenAt) : null);
214
+
161
215
  if (!existing) {
162
216
  const [row] = await tx.insert(billingObligations)
163
217
  .values({ organisationId, sourceSystem, sourceJobRef, sourceSeenAt: seenAt, ...facts })
164
218
  .returning();
165
219
  await addEvent(tx, row, 'observed', actor, {
166
- lineFingerprint: row.lineFingerprint, totalCents: row.totalCents, mappingComplete: row.mappingComplete,
220
+ lineFingerprint: row.lineFingerprint, totalCents: row.totalCents, mappingComplete: row.mappingComplete, sourceStatus: row.sourceStatus, sourceTotals: row.sourceTotals,
167
221
  });
168
- return row;
222
+ return recordSourceReview(tx, row, actor);
169
223
  }
170
224
 
171
225
  // An older report never replaces a newer one.
172
226
  if (seenAt < existing.sourceSeenAt) return existing;
173
- const changed = ['sourceInvoiceRef', 'caseRef', 'contactId', 'practitioner', 'shippedOn', 'lineFingerprint', 'totalCents']
174
- .filter((key) => String(existing[key] ?? '') !== String(facts[key] ?? ''));
227
+ const changed = ['sourceInvoiceRef', 'caseRef', 'contactId', 'practitioner', 'shippedOn', 'lineFingerprint', 'subtotalCents', 'taxCents', 'totalCents', 'sourceTotals']
228
+ .filter((key) => key === 'sourceTotals'
229
+ ? SOURCE_TOTAL_FIELDS.some((field) => (existing.sourceTotals?.[field] ?? null) !== (facts.sourceTotals?.[field] ?? null))
230
+ : String(existing[key] ?? '') !== String(facts[key] ?? ''));
175
231
  if (!changed.length) {
176
232
  const row = await update(tx, existing, {
177
233
  sourceSeenAt: seenAt, mappingComplete: facts.mappingComplete, lines, jobDate: facts.jobDate, sourceStatus: facts.sourceStatus,
178
234
  });
179
- await statusBillableAgain(tx, organisationId, existing, row, actor);
180
- return row;
235
+ await statusChanged(tx, organisationId, existing, row, actor);
236
+ return recordSourceReview(tx, row, actor);
181
237
  }
182
238
  const fields = { ...facts, sourceSeenAt: seenAt };
183
239
  let compare = null;
@@ -194,8 +250,8 @@ export async function observeJob(db, organisationId, job) {
194
250
  const row = await update(tx, existing, fields);
195
251
  await addEvent(tx, row, 'source_changed', actor, {
196
252
  changed,
197
- before: { lineFingerprint: existing.lineFingerprint, totalCents: existing.totalCents },
198
- after: { lineFingerprint: row.lineFingerprint, totalCents: row.totalCents },
253
+ before: { lineFingerprint: existing.lineFingerprint, totalCents: existing.totalCents, sourceTotals: existing.sourceTotals },
254
+ after: { lineFingerprint: row.lineFingerprint, totalCents: row.totalCents, sourceTotals: row.sourceTotals },
199
255
  ...(compare ? { compare: { outcome: compare.outcome, fields: compare.fields.map(({ field, changedIn }) => ({ field, changedIn })) } } : {}),
200
256
  ...(fields.holdState === 'review' ? { holdReason: fields.holdReason } : {}),
201
257
  });
@@ -204,15 +260,28 @@ export async function observeJob(db, organisationId, job) {
204
260
  } else if (compare) {
205
261
  await openReview(tx, organisationId, changedReview(row, compare));
206
262
  }
207
- await statusBillableAgain(tx, organisationId, existing, row, actor);
208
- return row;
263
+ await statusChanged(tx, organisationId, existing, row, actor);
264
+ return recordSourceReview(tx, row, actor);
209
265
  });
210
266
  }
211
267
 
268
+ // Each move of the work system status is an event, so "was the job shipped
269
+ // in this range" has an answer after the status moves on.
270
+ async function statusChanged(tx, organisationId, before, row, actor) {
271
+ if (String(before.sourceStatus ?? '') === String(row.sourceStatus ?? '')) return;
272
+ await addEvent(tx, row, 'status_changed', actor, { from: before.sourceStatus ?? null, to: row.sourceStatus ?? null });
273
+ await statusBillableAgain(tx, organisationId, before, row, actor);
274
+ }
275
+
276
+ // The calendar day of a time where the lab works (FLOW_TIME_ZONE).
277
+ function localDay(at) {
278
+ return new Intl.DateTimeFormat('en-CA', { timeZone: process.env.FLOW_TIME_ZONE || 'Australia/Sydney' }).format(at);
279
+ }
280
+
212
281
  // A job billable again after its status moved back (observeWithdrawn): the
213
282
  // alert is over when no invoice was made in the meantime.
214
283
  async function statusBillableAgain(tx, organisationId, before, row, actor) {
215
- if (row.invoiceId || String(before.sourceStatus ?? '') === String(row.sourceStatus ?? '')) return;
284
+ if (row.invoiceId) return;
216
285
  await resolveReviewFor(tx, organisationId, { kind: REVIEW_STATUS_BACK, entity: 'obligation', flowId: row.id, resolution: 'status_billable_again', actor });
217
286
  }
218
287
 
@@ -313,6 +382,8 @@ export async function useSourceLines(db, organisationId, obligationId, { actor,
313
382
  return db.transaction(async (tx) => {
314
383
  const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
315
384
  if (!obligation.invoiceId) throw new FlowError('invalid', 'the job has no invoice');
385
+ const sourceProblems = sourceBillingProblems(obligation);
386
+ if (sourceProblems.length) throw new FlowError('not_ready', sourceProblems.join('; '));
316
387
  const compare = await compareWithDraft(tx, organisationId, obligation, obligation);
317
388
  if (compare.outcome === 'conflict' && (compare.sent || compare.missing || !accept)) {
318
389
  throw new FlowError('conflict', compare.missing ? 'the invoice was not found' : compare.sent ? 'the invoice has left Flow; change it where it is now' : 'the draft changed in Flow too; a person must compare them', { fields: compare.fields });
@@ -786,7 +857,10 @@ export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOUR
786
857
  if (['authorised', 'part_paid'].includes(o.accountingState)) {
787
858
  return { view: 'invoiced', reasons: [`invoice ${o.accountingRef} is ${o.accountingState.replace('_', ' ')}`] };
788
859
  }
860
+ // Existing invoices own their stage. sending.js still checks source conflicts
861
+ // before approval or Send, after the non-chargeable rules.
789
862
  if (o.invoiceId || o.accountingState === 'drafted') return { view: 'drafted', reasons: ['a draft invoice exists'] };
863
+ if (sourceBillingProblems(o).length) return { view: 'review_required', reasons: draftBlockers(o, { now, sourceMaxAgeMs, accounting }) };
790
864
  const reasons = draftBlockers(o, { now, sourceMaxAgeMs, accounting });
791
865
  // Flow's own stage: a job is in work until a person or an automation marks
792
866
  // it ready to invoice (setReady).
@@ -795,7 +869,7 @@ export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOUR
795
869
  }
796
870
 
797
871
  function draftBlockers(o, { now, sourceMaxAgeMs, accounting = true }) {
798
- const reasons = [];
872
+ const reasons = sourceBillingProblems(o);
799
873
  if (o.holdState !== 'none') reasons.push(`the job is ${o.holdState}`);
800
874
  if (o.invoiceId) reasons.push('the job already has an invoice');
801
875
  if (o.accountingState === 'unknown') {
@@ -156,7 +156,7 @@ export function mapLine(line, context) {
156
156
  description: (item.description || item.name || original).trim(),
157
157
  quantity,
158
158
  unitAmountCents: config.linePrice === 'list' ? item.unitAmountCents : (price ?? item.unitAmountCents),
159
- taxCode: taxCode ?? line.taxCode ?? item.taxCode,
159
+ taxCode: line.taxCode ?? taxCode ?? item.taxCode,
160
160
  mappedBy: step,
161
161
  });
162
162
  const fromRow = (row, step) => {
@@ -180,7 +180,7 @@ export function mapLine(line, context) {
180
180
  if (row) {
181
181
  return {
182
182
  ...base, itemCode: null, accountCode: row.accountCode, description: row.description || original, quantity,
183
- unitAmountCents: price, taxCode: row.taxCode ?? line.taxCode, mappedBy: step,
183
+ unitAmountCents: price, taxCode: line.taxCode ?? row.taxCode ?? (line.taxCode === null ? null : undefined), mappedBy: step,
184
184
  };
185
185
  }
186
186
  break;
@@ -1,7 +1,7 @@
1
1
  import { and, eq } from 'drizzle-orm';
2
2
  import { billingObligations } from '../db/schema.js';
3
3
  import { FlowError } from './errors.js';
4
- import { observeJob, observeWithdrawn } from './ledger.js';
4
+ import { observeJob, observeWithdrawn, sourceBillingProblems } from './ledger.js';
5
5
  import { matchJobs, workJobMaker } from './pipeline.js';
6
6
 
7
7
  // Read before act (issue #141). Flow drafts and sends from its own copy of
@@ -50,7 +50,7 @@ export async function readBeforeAct(db, organisationId, obligations, { work, now
50
50
  continue;
51
51
  }
52
52
  const fresh = await reload(db, organisationId, obligation.id);
53
- outcomes.set(obligation.id, { outcome: fresh.holdState === 'review' ? 'review' : 'read', obligation: fresh });
53
+ outcomes.set(obligation.id, { outcome: fresh.holdState === 'review' || sourceBillingProblems(fresh).length ? 'review' : 'read', obligation: fresh });
54
54
  } else if (answer?.state === 'withdrawn') {
55
55
  // The ledger decides as for the feed (observeWithdrawn): deleted, or
56
56
  // moved back after its invoice, goes to review; moved back while only
@@ -124,6 +124,20 @@ export async function reconcileDays(db, organisationId, { integrations, from, to
124
124
  and e.kind in ('observed', 'source_changed') and e.actor <> ${HISTORY_ACTOR})`,
125
125
  ));
126
126
 
127
+ // A billable feed cannot prove absence. Check the remaining ledger refs
128
+ // against the source copy without billing filters. Older adapters retain
129
+ // the feed comparison until they declare workJobPresence.
130
+ const present = new Set(work.keys());
131
+ for (const adapter of workAdapters) {
132
+ if (!adapter.capabilities.workJobPresence) continue;
133
+ const jobRefs = [...new Set(obligations
134
+ .filter((o) => o.sourceSystem === adapter.id && !present.has(`${adapter.id}:${o.sourceJobRef}`))
135
+ .map((o) => o.sourceJobRef))];
136
+ for (let i = 0; i < jobRefs.length; i += FIND_BATCH) {
137
+ const { presentRefs } = await adapter.capabilities.workJobPresence({ organisationId, jobRefs: jobRefs.slice(i, i + FIND_BATCH) });
138
+ for (const ref of presentRefs) present.add(`${adapter.id}:${ref}`);
139
+ }
140
+ }
127
141
  const reported = new Set(workAdapters.map((a) => a.id));
128
142
  const inLedger = new Set();
129
143
  for (const o of obligations) {
@@ -139,7 +153,7 @@ export async function reconcileDays(db, organisationId, { integrations, from, to
139
153
  if (reported.has(o.sourceSystem)) {
140
154
  const key = `${o.sourceSystem}:${o.sourceJobRef}`;
141
155
  inLedger.add(key);
142
- if (!work.has(key)) entry.differences.missingInWork.push({ id: o.id, cents: o.totalCents });
156
+ if (!present.has(key)) entry.differences.missingInWork.push({ id: o.id, cents: o.totalCents });
143
157
  }
144
158
  }
145
159
  for (const [key, job] of work) {
@@ -5,7 +5,7 @@ import { FlowError } from './errors.js';
5
5
  import { listRules, ruleNonChargeable, rulePending, ruleSkip } from './invoice-rules.js';
6
6
  import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
7
7
  import { issueInvoice } from './invoices.js';
8
- import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
8
+ import { CHANGED_REASON, diffJobLines, sourceBillingProblems, STATUS_BACK_REASON } from './ledger.js';
9
9
  import { soleOrganisation } from './pipeline.js';
10
10
  import { needsRead, readAccountingFirst, readBeforeAct, READS_PER_PASS } from './read-before-act.js';
11
11
  import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
@@ -116,6 +116,12 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
116
116
  }
117
117
  // A draft whose job the ledger sent to review (the job changed after the
118
118
  // draft was made, or the work system deleted it) waits for a person.
119
+ for (const { obligation } of jobs) {
120
+ for (const reason of sourceBillingProblems(obligation)) {
121
+ const found = problem('job_review', 'Check the source invoice: ' + reason + '.', 'Source tax');
122
+ problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), found]);
123
+ }
124
+ }
119
125
  const reviewed = jobs.map((j) => j.obligation).filter((o) => o.holdState === 'review');
120
126
  for (const obligation of reviewed) {
121
127
  const { invoiceId, holdReason: reason } = obligation;
@@ -439,7 +445,8 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
439
445
  summary.waiting += 1;
440
446
  } else {
441
447
  const error = result.error ?? { code: `send_${result.outcome}`, message: `Sending ended as ${result.outcome}.` };
442
- await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message, ...(error.short ? { short: error.short } : {}) } });
448
+ const xero = xeroMatch(error);
449
+ await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message, ...(error.short ? { short: error.short } : {}), ...(xero ? { xero } : {}) } });
443
450
  summary.failed += 1;
444
451
  summary.refused = [...new Set([...(summary.refused ?? []), error.code])];
445
452
  const fact = refusalFact(known.get(result.invoiceId), error);
@@ -450,6 +457,17 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
450
457
  return summary;
451
458
  }
452
459
 
460
+ // The Xero invoice a refusal matched, kept so the page can link to it.
461
+ // Only an id, a number and a status. Anything else in the error stays out.
462
+ function xeroMatch(error) {
463
+ const xero = error?.details?.xero;
464
+ if (!xero || typeof xero !== 'object') return null;
465
+ if (!INVOICE_ID.test(xero.invoiceId ?? '')) return null;
466
+ const number = INVOICE_NUMBER.test(xero.number ?? '') ? xero.number : null;
467
+ const status = /^[A-Z0-9_]{1,32}$/.test(xero.status ?? '') ? xero.status : null;
468
+ return { invoiceId: xero.invoiceId, ...(number ? { number } : {}), ...(status ? { status } : {}) };
469
+ }
470
+
453
471
  function settle(db, organisationId, invoiceId, fields) {
454
472
  return db.update(invoices).set({ ...fields, sendUpdatedAt: sql`now()` })
455
473
  .where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId), eq(invoices.sendState, 'queued')));
@@ -0,0 +1 @@
1
+ ALTER TABLE "billing_obligations" ADD COLUMN IF NOT EXISTS "source_totals" jsonb;
@@ -0,0 +1,38 @@
1
+ CREATE TABLE IF NOT EXISTS "agent_audit" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
4
+ "grant_id" uuid NOT NULL,
5
+ "person_id" uuid NOT NULL,
6
+ "tool" text NOT NULL,
7
+ "status" integer NOT NULL,
8
+ "row_count" integer DEFAULT 0 NOT NULL,
9
+ "duration_ms" integer NOT NULL
10
+ );
11
+ --> statement-breakpoint
12
+ CREATE TABLE IF NOT EXISTS "agent_grants" (
13
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
14
+ "person_id" uuid NOT NULL,
15
+ "client_id" text NOT NULL,
16
+ "app_name" text NOT NULL,
17
+ "resource" text NOT NULL,
18
+ "organisation_id" uuid NOT NULL,
19
+ "access_level" text DEFAULT 'read' NOT NULL,
20
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
21
+ "last_used_at" timestamp with time zone,
22
+ "revoked_at" timestamp with time zone,
23
+ "failure_day" date,
24
+ "failure_count" integer DEFAULT 0 NOT NULL,
25
+ "paused_at" timestamp with time zone,
26
+ CONSTRAINT "agent_grants_person_client_resource" UNIQUE("person_id","client_id","resource"),
27
+ CONSTRAINT "agent_grants_read_only" CHECK (access_level = 'read')
28
+ );
29
+ --> statement-breakpoint
30
+ DO $flow_guard$ BEGIN
31
+ ALTER TABLE "agent_grants" ADD CONSTRAINT "agent_grants_person_id_people_id_fk" FOREIGN KEY ("person_id") REFERENCES "flow"."people"("id") ON DELETE cascade ON UPDATE no action;
32
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
33
+ END $flow_guard$;--> statement-breakpoint
34
+ DO $flow_guard$ BEGIN
35
+ ALTER TABLE "agent_grants" ADD CONSTRAINT "agent_grants_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "flow"."organisations"("id") ON DELETE no action ON UPDATE no action;
36
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
37
+ END $flow_guard$;--> statement-breakpoint
38
+ CREATE INDEX IF NOT EXISTS "agent_audit_created" ON "agent_audit" USING btree ("created_at");