runward 0.6.0
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.
- package/LICENSE +21 -0
- package/NOTICE.md +13 -0
- package/README.md +128 -0
- package/dist/cli.js +76 -0
- package/dist/commands/check.js +50 -0
- package/dist/commands/doctor.js +75 -0
- package/dist/commands/init.js +90 -0
- package/dist/commands/status.js +49 -0
- package/dist/commands/update.js +48 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/mission.js +96 -0
- package/dist/lib/paths.js +28 -0
- package/dist/lib/styles.js +60 -0
- package/dist/lib/tools.js +59 -0
- package/dist/lib/write.js +30 -0
- package/package.json +29 -0
- package/templates/mission/adr/ADR-0000-template.md +38 -0
- package/templates/mission/architecture.md +60 -0
- package/templates/mission/decision-matrix.md +37 -0
- package/templates/mission/evaluation-rubric.md +60 -0
- package/templates/mission/floor.md +47 -0
- package/templates/mission/framing.md +58 -0
- package/templates/mission/mission-contract.md +58 -0
- package/templates/mission/observability-schema.md +53 -0
- package/templates/mission/port-contract.md +63 -0
- package/templates/mission/reference-stack.md +70 -0
- package/templates/mission/runbook.md +70 -0
- package/templates/mission/shared-bricks.md +74 -0
- package/templates/mission/threat-model.md +53 -0
- package/templates/rules/async-job-guardrails.md +95 -0
- package/templates/rules/async-post-turn-pipeline.md +96 -0
- package/templates/rules/async-scheduled-maintenance.md +95 -0
- package/templates/rules/cache-three-tier-architecture.md +44 -0
- package/templates/rules/checklist-day-zero-project.md +97 -0
- package/templates/rules/checklist-pre-production-observability.md +99 -0
- package/templates/rules/checklist-pre-production-performance.md +109 -0
- package/templates/rules/checklist-pre-production-resilience.md +99 -0
- package/templates/rules/checklist-pre-production-security.md +86 -0
- package/templates/rules/config-secrets-boundary.md +57 -0
- package/templates/rules/config-typing-zod.md +78 -0
- package/templates/rules/contracts-governance.md +64 -0
- package/templates/rules/data-memory-consolidation.md +81 -0
- package/templates/rules/data-memory-invalidation.md +90 -0
- package/templates/rules/data-memory-scoring.md +154 -0
- package/templates/rules/data-migrations-forward-only.md +55 -0
- package/templates/rules/data-orphan-cleanup.md +142 -0
- package/templates/rules/data-ttl-types.md +100 -0
- package/templates/rules/eval-loop.md +52 -0
- package/templates/rules/frontier-deterministic-boundary.md +91 -0
- package/templates/rules/hexa-adapter-pattern.md +85 -0
- package/templates/rules/hexa-architecture.md +84 -0
- package/templates/rules/hexa-move-deterministic-out.md +93 -0
- package/templates/rules/hexa-recommended-stack.md +33 -0
- package/templates/rules/hexa-typescript-native.md +80 -0
- package/templates/rules/observability-alert-configuration.md +160 -0
- package/templates/rules/observability-llm-metrics.md +150 -0
- package/templates/rules/observability-startup-provider-log.md +128 -0
- package/templates/rules/observability-structured-json-logs.md +138 -0
- package/templates/rules/patterns-memory-router-tiered.md +139 -0
- package/templates/rules/patterns-prompt-compiler.md +126 -0
- package/templates/rules/patterns-request-id-propagation.md +137 -0
- package/templates/rules/process-adr-and-journal.md +50 -0
- package/templates/rules/provider-llm-auto-detection.md +54 -0
- package/templates/rules/provider-no-crash-missing-env.md +67 -0
- package/templates/rules/quality-codebase-metrics.md +115 -0
- package/templates/rules/quality-zod-input-validation.md +131 -0
- package/templates/rules/resilience-fail-open.md +65 -0
- package/templates/rules/resilience-multi-provider-fallback.md +100 -0
- package/templates/rules/resilience-retry-backoff.md +115 -0
- package/templates/rules/resilience-retryable-errors.md +107 -0
- package/templates/rules/routing-confidence-upgrade.md +89 -0
- package/templates/rules/routing-model-cost-ratios.md +68 -0
- package/templates/rules/routing-smart-complexity.md +131 -0
- package/templates/rules/scaling-db-connection-pooling.md +121 -0
- package/templates/rules/scaling-distributed-rate-limiting.md +141 -0
- package/templates/rules/scaling-state-externalization.md +84 -0
- package/templates/rules/security-prompt-injection.md +63 -0
- package/templates/rules/state-event-sourcing.md +66 -0
- package/templates/rules/tools-registry-pattern.md +153 -0
- package/templates/rules/tools-scope-atomicity.md +103 -0
- package/templates/targets/AGENTS.md +32 -0
- package/templates/workflows/architect.md +50 -0
- package/templates/workflows/brownfield.md +52 -0
- package/templates/workflows/decision-loop.md +52 -0
- package/templates/workflows/floor.md +60 -0
- package/templates/workflows/frame.md +69 -0
- package/templates/workflows/govern.md +55 -0
- package/templates/workflows/handover.md +55 -0
- package/templates/workflows/iterate.md +52 -0
- package/templates/workflows/method.md +53 -0
- package/templates/workflows/review.md +59 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Pre-Production Security Checklist
|
|
3
|
+
impact: CRITICAL
|
|
4
|
+
impactDescription: Ensures security requirements are met before production deployment
|
|
5
|
+
tags: [checklist, security, production, deployment]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Pre-Production Security Checklist
|
|
9
|
+
|
|
10
|
+
Security items that MUST be validated before any production deployment.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
SECURITY CHECKLIST
|
|
14
|
+
├── [ ] Secrets in env vars only (not in code, not in logs)
|
|
15
|
+
├── [ ] Input validation with Zod on ALL external inputs
|
|
16
|
+
├── [ ] Rate limiting configured and tested
|
|
17
|
+
├── [ ] CORS configured (specific origins, not '*')
|
|
18
|
+
├── [ ] Security headers set (CSP, HSTS, X-Frame-Options)
|
|
19
|
+
├── [ ] SQL injection protection (parameterized queries)
|
|
20
|
+
├── [ ] XSS protection (output encoding)
|
|
21
|
+
├── [ ] Authentication on all protected routes
|
|
22
|
+
├── [ ] Authorization checks (tenant isolation)
|
|
23
|
+
├── [ ] Prompt injection: observed content is data; lethal trifecta broken on the context window
|
|
24
|
+
├── [ ] Grounding guard on any model output that surfaces facts (fail-closed)
|
|
25
|
+
├── [ ] Secret never reaches the model (substitute only; real key at the network boundary)
|
|
26
|
+
└── [ ] Dependency audit (npm audit / yarn audit)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Incorrect:**
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
// Secrets in code
|
|
33
|
+
const apiKey = "sk-abc123..."; // BAD:
|
|
34
|
+
|
|
35
|
+
// No input validation
|
|
36
|
+
app.post('/api/project', async (req, res) => {
|
|
37
|
+
const { name, data } = req.body; // BAD: Unvalidated
|
|
38
|
+
await db.insert(projects).values({ name, data });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// CORS wide open
|
|
42
|
+
app.use(cors({ origin: '*' })); // BAD:
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Correct:**
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
// Secrets from environment
|
|
49
|
+
const apiKey = process.env.API_KEY;
|
|
50
|
+
|
|
51
|
+
// Zod validation
|
|
52
|
+
const CreateProjectSchema = z.object({
|
|
53
|
+
name: z.string().min(1).max(100),
|
|
54
|
+
data: z.record(z.unknown()),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
app.post('/api/project', async (req, res) => {
|
|
58
|
+
const validated = CreateProjectSchema.parse(req.body); // GOOD:
|
|
59
|
+
await db.insert(projects).values(validated);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Specific CORS origins
|
|
63
|
+
app.use(cors({
|
|
64
|
+
origin: ['https://app.example.com', 'https://admin.example.com'],
|
|
65
|
+
credentials: true,
|
|
66
|
+
}));
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Security Headers (set at the edge or in the framework):**
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
// next.config.js
|
|
73
|
+
const securityHeaders = [
|
|
74
|
+
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
|
|
75
|
+
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains' },
|
|
76
|
+
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
|
|
77
|
+
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
|
78
|
+
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
async headers() {
|
|
83
|
+
return [{ source: '/:path*', headers: securityHeaders }];
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
```
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Secrets at the Network Boundary, Never in the Model
|
|
3
|
+
impact: CRITICAL
|
|
4
|
+
impactDescription: Makes secret disclosure structurally impossible by keeping the real key out of the model and the domain
|
|
5
|
+
tags: [security, secrets, configuration, boundary]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Secrets at the Network Boundary, Never in the Model
|
|
9
|
+
|
|
10
|
+
> **A secret the model never saw cannot be disclosed.** This guard is structural, not behavioural, and it strips prompt injection of its most coveted loot.
|
|
11
|
+
|
|
12
|
+
Secrets and sensitive data are isolated behind typed configuration, never in the domain. One notch further for runtime secrets: the secret never crosses the boundary of the model or of the execution environment. The agent handles only a *substitute*; the real key is attached by infrastructure at the network boundary, toward the only explicitly authorised destinations, and is replaceable without redeployment.
|
|
13
|
+
|
|
14
|
+
**Incorrect:**
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
// Key in code, in the domain, and within reach of the model's context.
|
|
18
|
+
const MODEL_KEY = "sk-live-abc123"; // BAD: in code
|
|
19
|
+
function callModel(prompt: string) {
|
|
20
|
+
return fetch(GW, { headers: { Authorization: `Bearer ${MODEL_KEY}` }, ... });
|
|
21
|
+
}
|
|
22
|
+
// BAD: if the key sits in config the agent reads, an injection can ask for it.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Correct:**
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
// Domain knows a port, not a key. The adapter calls a gateway; the real
|
|
29
|
+
// credential is injected by infrastructure at the egress boundary.
|
|
30
|
+
interface ModelPort { complete(req: Req): Promise<Res>; }
|
|
31
|
+
|
|
32
|
+
// adapter: no secret in scope of the model's reasoning
|
|
33
|
+
class GatewayModelAdapter implements ModelPort {
|
|
34
|
+
// calls the internal gateway URL; the platform attaches the real key
|
|
35
|
+
// at the network edge toward the allowed destination only.
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Configuration discipline:**
|
|
40
|
+
|
|
41
|
+
- Secrets live in `.env` (git-ignored), read once into typed config, never committed, never logged.
|
|
42
|
+
- The domain depends on ports, never on environment variables.
|
|
43
|
+
- Egress credentials are attached by the platform at the network boundary, not carried through application code.
|
|
44
|
+
- Prefer rotation without redeployment (the substitute stays stable, the key behind it rotates).
|
|
45
|
+
|
|
46
|
+
**Transport, strict by default:** for Postgres/Neon, pin `sslmode=verify-full` (strict verification of both certificate and DNS name) so you do not depend on a driver's future default:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
postgresql://USER:PASS@HOST/DB?sslmode=verify-full&channel_binding=require
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Checklist:**
|
|
53
|
+
|
|
54
|
+
- [ ] No secret literal anywhere in source or logs.
|
|
55
|
+
- [ ] The real egress key is attached at the network boundary, not in app code.
|
|
56
|
+
- [ ] The model's context never contains a usable credential, only a substitute.
|
|
57
|
+
- [ ] DB connection string pins `sslmode=verify-full`.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Config Typing with Zod
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: Makes configuration type-safe and validated once at the boundary, so the rest of the system trusts it
|
|
5
|
+
tags: [configuration, typescript, validation]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Config Typing with Zod
|
|
9
|
+
|
|
10
|
+
Type and validate all configuration with Zod once, at startup, at the boundary. The domain then depends on a typed `Config` object, never on raw `process.env`. Secrets are read here and nowhere else (see *Secrets at the Network Boundary*).
|
|
11
|
+
|
|
12
|
+
> **Parse, do not scatter `process.env` across the codebase.** One schema, one validation, one typed object.
|
|
13
|
+
|
|
14
|
+
**Implementation:**
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
// src/config.ts
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
|
|
20
|
+
const ConfigSchema = z.object({
|
|
21
|
+
env: z.enum(["development", "production", "test"]),
|
|
22
|
+
port: z.coerce.number().positive().default(3000),
|
|
23
|
+
|
|
24
|
+
// Model provider resolved at the boundary, behind a port.
|
|
25
|
+
model: z.object({
|
|
26
|
+
provider: z.string(), // open string, not a vendor enum
|
|
27
|
+
gatewayUrl: z.string().url().optional(),
|
|
28
|
+
model: z.string(),
|
|
29
|
+
}),
|
|
30
|
+
|
|
31
|
+
// Database of record (required). Pin strict TLS.
|
|
32
|
+
database: z.object({
|
|
33
|
+
url: z.string().url(), // ...?sslmode=verify-full&channel_binding=require
|
|
34
|
+
poolSize: z.coerce.number().positive().default(20),
|
|
35
|
+
}),
|
|
36
|
+
|
|
37
|
+
// Optional services: present or absent, never crashing (graceful degradation).
|
|
38
|
+
optional: z.object({
|
|
39
|
+
search: z.boolean().default(false),
|
|
40
|
+
memory: z.boolean().default(false),
|
|
41
|
+
telemetry: z.boolean().default(false),
|
|
42
|
+
}),
|
|
43
|
+
|
|
44
|
+
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
48
|
+
|
|
49
|
+
export const config: Config = ConfigSchema.parse({
|
|
50
|
+
env: process.env.NODE_ENV ?? "development",
|
|
51
|
+
port: process.env.PORT,
|
|
52
|
+
model: {
|
|
53
|
+
provider: process.env.MODEL_PROVIDER ?? "gateway",
|
|
54
|
+
gatewayUrl: process.env.MODEL_GATEWAY_URL,
|
|
55
|
+
model: process.env.MODEL ?? "",
|
|
56
|
+
},
|
|
57
|
+
database: { url: process.env.DATABASE_URL, poolSize: process.env.DB_POOL_SIZE },
|
|
58
|
+
optional: {
|
|
59
|
+
search: !!process.env.SEARCH_API_KEY,
|
|
60
|
+
memory: !!process.env.MEMORY_URL,
|
|
61
|
+
telemetry: !!process.env.TELEMETRY_TOKEN,
|
|
62
|
+
},
|
|
63
|
+
logLevel: process.env.LOG_LEVEL,
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Discipline:**
|
|
68
|
+
|
|
69
|
+
- Provider fields are open strings, not vendor enums. The set of providers is not part of your contract; the port is. Adding a provider must not change this schema.
|
|
70
|
+
- Validation happens once; a malformed required value fails startup loud and early (fail fast). Optional services resolve to a boolean and degrade (fail-open) when absent.
|
|
71
|
+
- The domain imports `config` (typed), never `process.env`. Secrets never travel further than this boundary.
|
|
72
|
+
|
|
73
|
+
**Usage:**
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
import { config } from "@/config"; // fully typed, already validated
|
|
77
|
+
if (config.optional.search) { /* TypeScript knows the shape */ }
|
|
78
|
+
```
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Contract Governance (Versioned, Additive, Expand-then-Contract)
|
|
3
|
+
impact: CRITICAL
|
|
4
|
+
impactDescription: Lets the system evolve without breaking consumers, by governing the contract at the boundary rather than the implementation behind it
|
|
5
|
+
tags: [architecture, contracts, ports, versioning, compatibility]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Contract Governance
|
|
9
|
+
|
|
10
|
+
A port is a contract. The whole point of the hexagon is that you can change what is *behind* a port freely, but what *crosses* the port is governed. Break that discipline and the decoupling is cosmetic.
|
|
11
|
+
|
|
12
|
+
> **Behind the port: free. Across the port: governed, versioned, additive.**
|
|
13
|
+
|
|
14
|
+
**Rules of change at a boundary:**
|
|
15
|
+
|
|
16
|
+
1. **Additive by default.** Add new optional fields; never remove or repurpose an existing one in place.
|
|
17
|
+
2. **Versioned.** A breaking change is a new version of the contract, not an edit to the old one.
|
|
18
|
+
3. **Expand-then-contract** (the safe migration of any contract):
|
|
19
|
+
- *Expand*: introduce the new shape alongside the old, both supported.
|
|
20
|
+
- *Migrate*: move producers and consumers over, observed on real traffic.
|
|
21
|
+
- *Contract*: only once nothing reads the old shape, remove it.
|
|
22
|
+
4. **Consumer-driven.** The consumers' expectations are encoded as tests the producer must satisfy. The contract is owned jointly, not dictated by the producer.
|
|
23
|
+
|
|
24
|
+
**The schema-drift test (executable contract):**
|
|
25
|
+
|
|
26
|
+
The cheapest consumer-driven contract test is one that fails the build when the implementation drifts from the domain contract. In practice the domain `zod` types are the single source of truth, and a test asserts that the live SQL columns match them:
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
// test/schema-drift.test.ts (runs only when DATABASE_URL is set)
|
|
30
|
+
test("SQL columns match the domain contract", async () => {
|
|
31
|
+
const columns = await db.columnsOf("facts"); // live schema
|
|
32
|
+
const expected = factSchema.keyof().options; // zod domain type
|
|
33
|
+
expect(new Set(columns)).toEqual(new Set(expected.map(toSnakeCase)));
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
When the database and the domain type disagree, the build goes red before a single request is served. The contract is not a document anyone can forget; it is a test.
|
|
38
|
+
|
|
39
|
+
**The model port is a contract too.** Promoting a new model is the sibling of expand-then-contract: the candidate runs first in *shadow deployment* behind the same port, on real traffic but silent, its behavioural divergence measured against the serving model; only if divergence stays under a pre-set threshold do you shift a growing share of traffic, with immediate rollback on any regression. You never switch in one move.
|
|
40
|
+
|
|
41
|
+
**Incorrect:**
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
// Renaming a field in place. Every consumer breaks at once.
|
|
45
|
+
interface FactDTO { value: string; /* was: amount */ } // BAD: breaking, unversioned
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Correct:**
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
// Expand: add the new field, keep the old, mark it deprecated.
|
|
52
|
+
interface FactDTO {
|
|
53
|
+
/** @deprecated use `value` */ amount?: string;
|
|
54
|
+
value: string;
|
|
55
|
+
}
|
|
56
|
+
// Migrate consumers, observe on real traffic, THEN contract (remove `amount`).
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Checklist before changing anything that crosses a boundary:**
|
|
60
|
+
|
|
61
|
+
- [ ] Is the change additive? If not, it is a new version.
|
|
62
|
+
- [ ] Is there a contract test (schema-drift, consumer test) that will catch a regression?
|
|
63
|
+
- [ ] Are the old and new shapes both live during the migration (expand-then-contract)?
|
|
64
|
+
- [ ] For a model swap: shadow first, switch by stages, rollback ready.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Reversible Memory Consolidation (Merge, Summarize, Keep Pointers)
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: Keeps long-term memory compact without destroying evidence, so every consolidated fact can be unfolded back to its sources at audit time
|
|
5
|
+
tags: [data, memory, consolidation, summarization, audit]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Reversible Memory Consolidation
|
|
9
|
+
|
|
10
|
+
> **Consolidation is a projection, not a rewrite. You never consolidate the source of truth.**
|
|
11
|
+
|
|
12
|
+
A memory store that only accumulates degrades: near-duplicates crowd out signal, retrieval ranks ten variants of the same fact, and the context budget fills with redundancy. The answer is consolidation — merging near-duplicates, summarizing episodes into higher-level facts — but done naively it destroys the very thing memory exists for: the ability to say *why* the system believed what it believed.
|
|
13
|
+
|
|
14
|
+
The rule is that consolidation must be **reversible**. Two constraints make it so:
|
|
15
|
+
|
|
16
|
+
1. **The immutable journal is never consolidated.** Consolidation operates on the derived working view only (see *State as an Immutable Journal*). The journal remains the append-only source of truth; a summary is one more derived artifact, never a replacement for what it summarizes.
|
|
17
|
+
2. **Every consolidated item keeps pointers to its sources.** A merged fact records the ids of the near-duplicates it absorbed; an episode summary records the ids of the events it condenses. Given a consolidated memory, you can always unfold it back to the raw facts.
|
|
18
|
+
|
|
19
|
+
**Incorrect:**
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
// Destructive consolidation: summarize, then delete the originals.
|
|
23
|
+
async function consolidate(memories: Memory[]): Promise<void> {
|
|
24
|
+
const summary = await summarize(memories);
|
|
25
|
+
await memoryStore.insert(summary);
|
|
26
|
+
await memoryStore.deleteMany(memories.map(m => m.id)); // BAD: evidence gone
|
|
27
|
+
// A month later: "why did the agent think the client wanted X?"
|
|
28
|
+
// The summary says so, the facts behind it no longer exist.
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Correct:**
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
// Reversible consolidation: the summary points at its sources,
|
|
36
|
+
// the sources are demoted out of retrieval, nothing is destroyed.
|
|
37
|
+
interface ConsolidatedMemory extends Memory {
|
|
38
|
+
kind: 'merged' | 'episode-summary';
|
|
39
|
+
sourceIds: string[]; // pointers to the raw facts
|
|
40
|
+
consolidatedAt: Date;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function consolidate(memories: Memory[]): Promise<ConsolidatedMemory> {
|
|
44
|
+
const summary = await summarize(memories);
|
|
45
|
+
const consolidated = await memoryStore.insert({
|
|
46
|
+
...summary,
|
|
47
|
+
kind: 'episode-summary',
|
|
48
|
+
sourceIds: memories.map(m => m.id),
|
|
49
|
+
consolidatedAt: new Date(),
|
|
50
|
+
});
|
|
51
|
+
// Sources leave the retrieval index, not the store.
|
|
52
|
+
await memoryStore.markSuperseded(memories.map(m => m.id), consolidated.id);
|
|
53
|
+
return consolidated;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Audit: unfold a consolidated memory to the raw facts as of a decision.
|
|
57
|
+
async function unfold(memoryId: string, asOf: Date): Promise<Memory[]> {
|
|
58
|
+
const memory = await memoryStore.get(memoryId);
|
|
59
|
+
if (!('sourceIds' in memory)) return [memory];
|
|
60
|
+
const sources = await memoryStore.getMany(memory.sourceIds);
|
|
61
|
+
return sources.filter(s => s.createdAt <= asOf);
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Two consolidation moves, same discipline for both:
|
|
66
|
+
|
|
67
|
+
- **Near-duplicate merge**: facts above a similarity threshold collapse into one canonical statement carrying the union of their provenance. The duplicates are superseded, not deleted.
|
|
68
|
+
- **Episode summarization**: a bounded slice of interaction events becomes one higher-level fact ("the user prefers weekly reports on Monday"), pointing at the events that support it.
|
|
69
|
+
|
|
70
|
+
**Why this matters:**
|
|
71
|
+
|
|
72
|
+
- **Audit**: when a decision is questioned, you replay it at its timestamp and unfold every consolidated memory that was in context back to the raw facts. A summary that cannot be unfolded is an assertion without evidence.
|
|
73
|
+
- **Correcting bad summaries**: summarization is a model operation and will sometimes be wrong. With pointers, a bad summary is fixed by re-summarizing the same sources; without them, the error is permanent and silently propagates.
|
|
74
|
+
- **Retrieval quality**: superseded items leave the index, so ranking stops splitting relevance across ten variants of one fact — without paying for it in lost history.
|
|
75
|
+
|
|
76
|
+
**Checklist:**
|
|
77
|
+
|
|
78
|
+
- [ ] Consolidation runs on the derived view only; the immutable journal is untouched.
|
|
79
|
+
- [ ] Every merged or summarized memory carries `sourceIds` pointing to its raw facts.
|
|
80
|
+
- [ ] Superseded sources are removed from retrieval, never physically deleted by consolidation.
|
|
81
|
+
- [ ] An `unfold` path can expand any consolidated memory to its sources as of a given timestamp.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Bi-Temporal Memory Invalidation (Contradicted Facts Are Dated, Not Deleted)
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: Lets memory change its mind without lying about the past, by tracking when a fact was learned and when it was valid as two separate timelines
|
|
5
|
+
tags: [data, memory, invalidation, bi-temporal, audit]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Bi-Temporal Memory Invalidation
|
|
9
|
+
|
|
10
|
+
> **A contradicted fact is invalidated with a date. It is never deleted.**
|
|
11
|
+
|
|
12
|
+
Facts about the world change: the user moves teams, the client changes their budget, a preference flips. A memory store that handles this by overwriting or deleting the old fact destroys two things at once — the ability to explain past decisions ("it acted on what it knew then"), and the ability to detect that a fact *changed*, which is often information in itself.
|
|
13
|
+
|
|
14
|
+
The structural answer is **bi-temporal** memory. Every fact carries two independent timelines:
|
|
15
|
+
|
|
16
|
+
- **Learned time** (`recordedAt` / `supersededAt`): when the system came to know the fact, and when it stopped treating it as current.
|
|
17
|
+
- **Valid time** (`validFrom` / `validTo`): the period in the world during which the fact held true.
|
|
18
|
+
|
|
19
|
+
The two are different and both matter. "The budget is 50k" may have been *true* from January but only *learned* in March; a decision made in February is judged against what was learned by February, not against what turned out to be true.
|
|
20
|
+
|
|
21
|
+
**Incorrect:**
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// Overwrite on contradiction: the past is silently rewritten.
|
|
25
|
+
async function updateFact(subject: string, newValue: string): Promise<void> {
|
|
26
|
+
await memoryStore.update({ subject }, { value: newValue }); // BAD: old value gone
|
|
27
|
+
// "Why did the agent quote 30k in February?" — no way to know
|
|
28
|
+
// the store ever said 30k. The audit trail now contradicts itself.
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Correct:**
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
interface BiTemporalFact {
|
|
36
|
+
id: string;
|
|
37
|
+
subject: string;
|
|
38
|
+
value: string;
|
|
39
|
+
source: 'user' | 'system' | 'inferred';
|
|
40
|
+
authority: number; // e.g. user-stated > inferred
|
|
41
|
+
recordedAt: Date; // learned time: when we came to know it
|
|
42
|
+
supersededAt: Date | null; // learned time: when we stopped believing it
|
|
43
|
+
validFrom: Date; // valid time: when it became true in the world
|
|
44
|
+
validTo: Date | null; // valid time: when it stopped being true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A contradiction invalidates with a date; nothing is deleted.
|
|
48
|
+
async function assertFact(next: Omit<BiTemporalFact, 'id' | 'supersededAt'>): Promise<void> {
|
|
49
|
+
const current = await memoryStore.findCurrent(next.subject);
|
|
50
|
+
if (current) {
|
|
51
|
+
// Most recently learned wins — unless the standing fact
|
|
52
|
+
// comes from a more authoritative source.
|
|
53
|
+
if (current.authority > next.authority) return; // keep the standing fact
|
|
54
|
+
await memoryStore.invalidate(current.id, {
|
|
55
|
+
supersededAt: next.recordedAt, // we stopped believing it now
|
|
56
|
+
validTo: next.validFrom, // it stopped being true then
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
await memoryStore.insert({ ...next, supersededAt: null });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Two distinct questions, two distinct queries.
|
|
63
|
+
const nowView = await memoryStore.query({ supersededAt: null, validTo: null }); // true now
|
|
64
|
+
const asOfT = await memoryStore.query({
|
|
65
|
+
recordedAt: { lte: T }, supersededAtAfterOrNull: T, // what we knew at T
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Resolution order on contradiction:** the most recently learned fact invalidates the older one — *unless* the older fact comes from a more authoritative source (a user's explicit statement outranks a model's inference; a verified system-of-record field outranks both). Authority is compared first, recency second.
|
|
70
|
+
|
|
71
|
+
**Physical deletion stays exceptional.** Two cases only:
|
|
72
|
+
|
|
73
|
+
1. **Derived data under retention**: projections, caches and indexes rebuilt from the journal may be purged on their TTL (see *TTL by Data Type*).
|
|
74
|
+
2. **Mandated erasure**: a legal right-to-erasure request removes the data wherever it lives, including the journal, through a documented, non-routine procedure.
|
|
75
|
+
|
|
76
|
+
Everything else is invalidation with a date.
|
|
77
|
+
|
|
78
|
+
**Why this matters:**
|
|
79
|
+
|
|
80
|
+
- **Audit**: "what did the system believe at the time of decision D?" has an exact answer — replay the learned timeline at D. Overwrites make that question unanswerable.
|
|
81
|
+
- **Correct behaviour under change**: the agent can say "your budget was 30k until March, 50k since" instead of flattening history into the latest value.
|
|
82
|
+
- **Safe contradiction handling**: injected or mistaken "corrections" don't destroy the standing fact; they sit next to it, dated and sourced, and can be rolled back by re-invalidating.
|
|
83
|
+
|
|
84
|
+
**Checklist:**
|
|
85
|
+
|
|
86
|
+
- [ ] Every fact carries both learned time and valid time.
|
|
87
|
+
- [ ] Contradictions invalidate with a date; no routine code path deletes facts.
|
|
88
|
+
- [ ] Recency wins on conflict only at equal or higher source authority.
|
|
89
|
+
- [ ] "True now" and "known at time T" are two distinct, supported queries.
|
|
90
|
+
- [ ] Physical deletion is limited to derived data under retention and mandated erasure.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Memory Scoring Formula
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: Provides optimal memory retrieval ranking
|
|
5
|
+
tags: [data, memory, scoring, algorithm]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Memory Scoring Formula
|
|
9
|
+
|
|
10
|
+
A comprehensive scoring formula for memory retrieval that balances multiple factors.
|
|
11
|
+
|
|
12
|
+
The weights, decay constant and score thresholds below are a **starting point, not calibrated truth** — recalibrate them against your real traffic (retrieval hit quality, pruning regret) before trusting them.
|
|
13
|
+
|
|
14
|
+
**Complete Formula:**
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
interface Memory {
|
|
18
|
+
id: string;
|
|
19
|
+
content: string;
|
|
20
|
+
embedding: number[];
|
|
21
|
+
importance: number; // 0-1, user or system assigned
|
|
22
|
+
accessCount: number; // Times retrieved
|
|
23
|
+
lastAccessedAt: Date;
|
|
24
|
+
createdAt: Date;
|
|
25
|
+
source: 'user' | 'system' | 'inferred';
|
|
26
|
+
verified: boolean; // User confirmed accuracy
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ScoringWeights {
|
|
30
|
+
similarity: number;
|
|
31
|
+
recency: number;
|
|
32
|
+
importance: number;
|
|
33
|
+
frequency: number;
|
|
34
|
+
verification: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const defaultWeights: ScoringWeights = {
|
|
38
|
+
similarity: 0.35,
|
|
39
|
+
recency: 0.25,
|
|
40
|
+
importance: 0.20,
|
|
41
|
+
frequency: 0.10,
|
|
42
|
+
verification: 0.10,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function computeMemoryScore(
|
|
46
|
+
memory: Memory,
|
|
47
|
+
queryEmbedding: number[],
|
|
48
|
+
weights = defaultWeights
|
|
49
|
+
): number {
|
|
50
|
+
// 1. Semantic similarity (cosine). Cosine ranges over [-1, 1];
|
|
51
|
+
// clamp to [0, 1] before weighting so an anti-correlated embedding
|
|
52
|
+
// cannot drag the combined score negative.
|
|
53
|
+
const similarity = Math.max(0, Math.min(1, cosineSimilarity(memory.embedding, queryEmbedding)));
|
|
54
|
+
|
|
55
|
+
// 2. Time decay (exponential): 60-day time constant, i.e. a
|
|
56
|
+
// half-life of ~41.6 days (60 * ln 2). For a literal 60-day
|
|
57
|
+
// half-life, use Math.pow(2, -daysSinceAccess / 60) instead.
|
|
58
|
+
const daysSinceAccess = (Date.now() - memory.lastAccessedAt.getTime()) / DAY_MS;
|
|
59
|
+
const recency = Math.exp(-daysSinceAccess / 60);
|
|
60
|
+
|
|
61
|
+
// 3. Importance (direct from memory)
|
|
62
|
+
const importance = memory.importance;
|
|
63
|
+
|
|
64
|
+
// 4. Access frequency (logarithmic, capped)
|
|
65
|
+
const frequency = Math.min(Math.log10(memory.accessCount + 1) / 2, 1);
|
|
66
|
+
|
|
67
|
+
// 5. Verification bonus
|
|
68
|
+
const verification = memory.verified ? 1 : 0.5;
|
|
69
|
+
|
|
70
|
+
// Weighted combination
|
|
71
|
+
const score =
|
|
72
|
+
similarity * weights.similarity +
|
|
73
|
+
recency * weights.recency +
|
|
74
|
+
importance * weights.importance +
|
|
75
|
+
frequency * weights.frequency +
|
|
76
|
+
verification * weights.verification;
|
|
77
|
+
|
|
78
|
+
return score;
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Context-Aware Weights:**
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// Adjust weights based on query type
|
|
86
|
+
function getWeightsForContext(queryType: string): ScoringWeights {
|
|
87
|
+
switch (queryType) {
|
|
88
|
+
case 'factual':
|
|
89
|
+
// Prioritize verified, important memories
|
|
90
|
+
return {
|
|
91
|
+
similarity: 0.30,
|
|
92
|
+
recency: 0.10,
|
|
93
|
+
importance: 0.25,
|
|
94
|
+
frequency: 0.10,
|
|
95
|
+
verification: 0.25,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
case 'recent':
|
|
99
|
+
// Prioritize recent memories
|
|
100
|
+
return {
|
|
101
|
+
similarity: 0.25,
|
|
102
|
+
recency: 0.40,
|
|
103
|
+
importance: 0.15,
|
|
104
|
+
frequency: 0.10,
|
|
105
|
+
verification: 0.10,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
case 'exploratory':
|
|
109
|
+
// Balance all factors, favor similarity
|
|
110
|
+
return {
|
|
111
|
+
similarity: 0.40,
|
|
112
|
+
recency: 0.20,
|
|
113
|
+
importance: 0.20,
|
|
114
|
+
frequency: 0.10,
|
|
115
|
+
verification: 0.10,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
default:
|
|
119
|
+
return defaultWeights;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Cosine Similarity Implementation:**
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
function cosineSimilarity(a: number[], b: number[]): number {
|
|
128
|
+
if (a.length !== b.length) {
|
|
129
|
+
throw new Error('Vectors must have same length');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let dotProduct = 0;
|
|
133
|
+
let normA = 0;
|
|
134
|
+
let normB = 0;
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < a.length; i++) {
|
|
137
|
+
dotProduct += a[i] * b[i];
|
|
138
|
+
normA += a[i] * a[i];
|
|
139
|
+
normB += b[i] * b[i];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const magnitude = Math.sqrt(normA) * Math.sqrt(normB);
|
|
143
|
+
return magnitude === 0 ? 0 : dotProduct / magnitude;
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Score Interpretation:**
|
|
148
|
+
|
|
149
|
+
| Score Range | Interpretation |
|
|
150
|
+
|-------------|----------------|
|
|
151
|
+
| 0.8 - 1.0 | Highly relevant, should definitely include |
|
|
152
|
+
| 0.6 - 0.8 | Relevant, include if space permits |
|
|
153
|
+
| 0.4 - 0.6 | Marginally relevant, include for deep queries |
|
|
154
|
+
| 0.0 - 0.4 | Low relevance, consider for pruning |
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Forward-Only, Branch-First Migrations
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: Keeps schema change safe and auditable by making migrations additive, ordered, and tested on a branch before main
|
|
5
|
+
tags: [data, migrations, database, schema, safety]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Forward-Only, Branch-First Migrations
|
|
9
|
+
|
|
10
|
+
Schema evolution is a migration of state, not a rewrite. Treat migrations like contract changes: additive, ordered, reversible by rolling forward, and proven on a branch before they touch production data.
|
|
11
|
+
|
|
12
|
+
> **Forward-only, additive, tested on a branch, then main.**
|
|
13
|
+
|
|
14
|
+
**Rules:**
|
|
15
|
+
|
|
16
|
+
1. **Versioned and contiguous.** Files are numbered `0001`, `0002`, ... A runner applies them in order and refuses a gap or a re-order (anti-skip guard).
|
|
17
|
+
2. **Forward-only.** No destructive down-migration in normal operation; you fix forward with a new migration. Removal of a column is the *contract* phase of expand-then-contract, run only once nothing reads it.
|
|
18
|
+
3. **Branch-first.** Run the migration on a throwaway branch (Neon copy-on-write costs ~0), verify the schema and the schema-drift test, keep the branch as a return net, then apply to main.
|
|
19
|
+
4. **Idempotent seeds.** Seed with `on conflict (id) do nothing` so re-running is safe.
|
|
20
|
+
|
|
21
|
+
**Migration runner guard (the order is enforced, not trusted):**
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// scripts/migrate.ts - transactional, contiguity + anti-skip
|
|
25
|
+
const applied = await db.appliedMigrations(); // e.g. [1, 2]
|
|
26
|
+
const onDisk = listMigrations(); // e.g. [1, 2, 3]
|
|
27
|
+
assertContiguous(onDisk); // no gap 1,3 without 2
|
|
28
|
+
assertNoReorder(applied, onDisk); // applied prefix unchanged
|
|
29
|
+
for (const m of onDisk.filter(m => !applied.includes(m.n))) {
|
|
30
|
+
await db.tx(async (t) => { await t.run(m.sql); await t.recordApplied(m.n); });
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Incorrect:**
|
|
35
|
+
|
|
36
|
+
```sql
|
|
37
|
+
-- Editing an applied migration in place, or dropping a live column.
|
|
38
|
+
ALTER TABLE facts DROP COLUMN value; -- BAD: breaks readers, irreversible
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Correct:**
|
|
42
|
+
|
|
43
|
+
```sql
|
|
44
|
+
-- 0007_add_period.sql (additive, forward-only)
|
|
45
|
+
ALTER TABLE facts ADD COLUMN period text; -- expand
|
|
46
|
+
-- backfill in the same or a later migration; readers move over;
|
|
47
|
+
-- only later, 0009_drop_legacy.sql removes what nothing reads (contract).
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Checklist:**
|
|
51
|
+
|
|
52
|
+
- [ ] Migrations are numbered, contiguous, applied in order by a runner.
|
|
53
|
+
- [ ] No edit to an already-applied migration; fix forward.
|
|
54
|
+
- [ ] Proven on a branch (schema-drift green) before main.
|
|
55
|
+
- [ ] Column removal is the contract phase, after readers have moved.
|