ctx — Shared Memory for an Operator and Their Agents

Version: 1.0 · 2026-10-15 Status: Stable (§1.4). Audience: Developers who want to build a persistent memory that they and the AI agents they work with share, in any language, for any agent harness; and the AI agents they ask to build it. Author: LangSyn License: CC BY 4.0, credit to LangSyn (see License)

If you are an AI agent and your operator pointed you at this document to set up a local context, go to §13, "Setting up ctx (for agents)". It says what to ask your operator, what to build, how to wire it into your harness, and how to prove that it conforms (Appendix B). The sections before it are the reference §13 relies on.


1. Status and scope

This document specifies ctx: one local store of short, attributed entries that a human operator and every AI agent they work with read and write through one tool; a one-screen brief that every agent session starts from; and a one-page session summary that hands each session over to the next.

It specifies observable behaviour and the files a context is kept in, not an implementation. A conforming ctx can be written in any language and serve any harness that can run a shell command; it keeps its data in the files of §8, so that another implementation can take over a context.

ctx solves three problems that appear as soon as one person works with agents over weeks:

ctx is not a vector database, a chat log or a harness plugin. It holds what the operator and the agents decided, found, built and still have open, in entries short enough to read.

1.1 Design principles

  1. One memory, outside every harness. A harness's private memory holds at most a pointer to ctx.
  2. Mark, don't rewrite. A correction is recorded as a status on the entry it corrects, so what was believed, and when, stays visible.
  3. Say how you know. Each entry states its basis — verified, reported or inferred — instead of a confidence number.
  4. Say who wrote it. Whose knowledge an entry is (its source) and who wrote it into ctx (its author) are separate fields.
  5. Start small. A session starts from one bounded screen, not the whole store.
  6. Hand over in writing. Each session ends with a one-page summary; the next starts from it.
  7. Plain files, plain text. The store is readable without the tool. Everything a harness needs arrives on standard output.

1.2 Conformance

The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, RECOMMENDED, MAY and OPTIONAL are to be interpreted as described in BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all capitals.

An implementation is conforming when its observable behaviour meets every MUST of the core level (§10). Sections marked informative state no requirements.

1.3 Terms

Term Meaning
Operator The human who owns the context and directs the agents.
Agent An AI model doing work through a harness.
Harness The program that runs an agent: a terminal coding agent, an IDE assistant, a chat client with tool use.
Session One run of one agent, from its start to its handover.
Context root The directory that holds one context (§8.1).
Store One of the three named collections of entries (§3.1).
Entry One keyed record in a store (§3.3).

1.4 Versions and stability

This is version 1.0, and it is stable. Revisions 1.x only clarify the text or add optional features (§10): an implementation that conforms to 1.0, and the files of a context it wrote (§8), stay conforming under every 1.x revision. A change that would break either is version 2.0.

An implementation states the version it conforms to, in its help or its documentation.

2. Working model

3. Data model

3.1 Stores

A context has exactly three stores:

Store Holds Typical writers
operator The operator's standing instructions, preferences and constraints. The operator; an agent recording the operator's own words.
shared Everything else: decisions, findings, results, issues, plans, references. Everyone.
agent Agents' working notes that are of little use to the operator. Agents.

Every operation on one entry (§4.1 to §4.5) MUST default to the shared store; search and list default to all stores (§4.6).

Who may write which store is a convention, stated in the rules (§6). Implementations MAY enforce it. An agent SHOULD write to the operator store only to record what the operator said, with source set to operator.

3.2 Keys

A key is a non-empty string, unique within its store. The same key MAY exist in more than one store; the entries are unrelated.

Keys SHOULD be lowercase snake_case of ASCII letters, digits and underscores. Entries about an event SHOULD end in the event's date, _YYYY_MM_DD, so that keys stay unique and sort by date. Prefixes such as decision_, issue_, result_, finding_, plan_ and reference_ are RECOMMENDED for people and for search; the brief relies on tags, not on prefixes, except where §5.2 says otherwise.

An implementation MAY refuse a key that holds whitespace or control characters.

The key current_focus in the shared store is reserved: it holds one paragraph on what the work is about now (§5.2). It is the one entry that is expected to be overwritten.

3.3 Entry fields

Field Type Req. Meaning
value any JSON value MUST The content. Usually text.
source operator | agent | both | system MUST Whose knowledge this is.
author string SHOULD Who wrote it into ctx (§3.4).
basis verified | reported | inferred MUST How the writer knows (§3.5).
scope global | session MUST Lifetime (§3.7).
tags array of non-empty strings MUST (may be empty) Topics, projects and kinds.
derivation string MAY How the value was reached: the sources, the reasoning, related keys.
created timestamp MUST When the key was first written. Never changes.
timestamp timestamp MUST When the value or the status last changed.
status amended | superseded | resolved MAY Absent means current (§3.6).
status_timestamp timestamp with status When the status was set.
status_note string MAY Why.
superseded_by string with superseded The key that replaces this one, in the same store.

Timestamps are RFC 3339 in UTC with a Z suffix, e.g. 2026-10-13T09:41:07Z. Tools SHOULD display them in local time.

An implementation MUST preserve fields it does not know when it updates an entry, so that two implementations can share one context. It SHOULD likewise keep top-level keys of a store file that it does not know (§8.2).

3.4 Source and author

source says whose knowledge the entry is; author says who typed it in. When an agent records an instruction the operator gave, the entry has source: operator and author set to the agent's name. When the operator records an agent's finding by hand, it has source: agent and author set to the operator's name. both is for knowledge the operator and an agent reached together; system is for entries written by a tool on its own (an import, a migration).

The author is taken from the environment variable CTX_AUTHOR (§9.2), without leading or trailing whitespace. An empty or blank CTX_AUTHOR counts as unset. When CTX_AUTHOR is set and the caller gives no source, the source MUST default to agent; otherwise it MUST default to operator. An explicit source always wins.

3.5 Basis

Basis Means Example
verified The writer checked it against the thing itself. Ran the test suite: 214 of 214 pass. Read the config file: the port is 8443.
reported Someone or something said it, and the writer did not check. The operator said the deploy is frozen until Friday. The vendor's docs say the limit is 100 requests per second.
inferred The writer concluded it. The timeout is probably what drops the long uploads.

A new entry's basis MUST default to inferred. verified MUST be claimed explicitly, and only for a value the writer checked in the session that wrote it.

When an existing entry's value is replaced and no basis is given, the entry MUST keep its basis. So a writer that replaces a value it has not checked MUST give the basis it has (reported or inferred): it would otherwise inherit the old one. Changing an entry's basis alone MUST NOT change its timestamp: the basis is a claim about the value's provenance, not a new value.

3.6 Status

An entry without status is current. Setting a status never removes the value:

Status Use Effect on the entry
amended The value is partly wrong or out of date, and a note fixes it. The note is prepended to the value as [Amended YYYY-MM-DD: <note>] followed by a newline, YYYY-MM-DD being the UTC date of status_timestamp. A value that is not a string becomes its JSON text first, so the amended value is a string.
superseded Another entry replaces this one. superseded_by names the replacement. The value is unchanged.
resolved An issue or open item is closed. The value is unchanged; the note says how.

Setting a status MUST set status_timestamp and timestamp to now and, when an author is known, author; when none is known, the old author stays. A new status replaces the old one whole: status_note holds the new note, or is absent, and superseded_by is present only with superseded. An amend's note is also its status_note. Amending an amended entry stacks the notes, the newest first. Replacing an entry's value (§4.2) MUST clear its status fields: a new value is a new claim.

Every display of an entry MUST show its status, with the replacement key for superseded, next to its value.

3.7 Scope

Projects are tags, not scopes.

4. Operations

This section is language-neutral. §9 maps it to a command line.

An operation that names a key that does not exist MUST fail, and MUST report the failure in a way the caller can test (on a command line: a non-zero exit status).

4.1 get

get(store, key) returns the entry. get across all stores returns every entry with that key.

4.2 set

set(store, key, value, source?, basis?, scope?, tags?, derivation?) writes an entry.

Because source is not kept, an agent that rewords one of the operator's instructions MUST give source operator again; otherwise the entry becomes the agent's, and drops out of the brief's standing instructions (§5.2).

4.3 amend, supersede, resolve

Each sets the status as §3.6 describes.

4.4 set basis

basis(store, key, basis) sets the basis and, when an author is known, author. It MUST NOT change timestamp (§3.5).

4.5 delete

delete(store, key) removes the entry for good. It SHOULD be kept for mistakes: a wrong key, a duplicate, a secret written by accident. Knowledge that turned out wrong is marked (§3.6), not deleted.

4.6 search and list

search(pattern, filters) returns the entries whose key or value contains the pattern, case-insensitively, as a plain substring (not a regular expression). An empty pattern matches every entry. A value that is not a string SHOULD be matched on its JSON text (§9.1).

get, search, list, brief and start MUST NOT change the stores; they MAY update a cache (§8.2).

list(filters) returns the entries that pass the filters.

Results come in an order the implementation documents (in its help, for example), and a limit cuts them after ordering.

Filters, combined with AND:

Filter Passes when
store the entry is in this store (default: all stores)
tag (repeatable) the entry has any of the given tags
source the source matches
status the status matches (an implementation MAY offer current, for entries without a status)
scope the scope matches
since timestamp is at or after the given time: an RFC 3339 timestamp, or a date YYYY-MM-DD meaning 00:00 UTC that day. Times are compared as points in time, not as text (…:07.5Z is after …:07Z)
limit at most this many results (search; RECOMMENDED default 50). list MAY take it

Each result MUST name its store and key.

5. Session start

5.1 Start payload

An implementation MUST provide one operation, start, that prints the start payload to standard output, in this order:

  1. The rules: the content of RULES.md in the context root (§6), verbatim, under a heading.
  2. The brief (§5.2).
  3. The newest session summary (§7), in full, under a heading that names its file.

start MUST succeed with exit status 0 when the context is empty or has no summary yet, and say so, so that a harness hook never fails a session. It MUST NOT write anything.

5.2 Brief

brief prints one bounded screen: its length MUST NOT grow with the size of the store. Sections appear in this order; an empty section MAY be left out, except that the header is always printed.

# Section Contents RECOMMENDED bounds
0 Header Local date and time; the entry count of each store; how to read an entry in full, naming its store (get defaults to shared, §3.1). one line
1 CURRENT FOCUS The value of current_focus in shared. 600 characters
2 STANDING INSTRUCTIONS Entries in operator and shared with source: operator and a tag of directive, preference or constraint, that are not decisions: neither tagged decision nor keyed decision… (section 4's test, without its store and age limits); newest first. 12 items, 130 characters each
3 OPEN The first line of each top-level bullet (a line starting - ) in the newest session summary's Open section (§7): from the first line that is ## Open, in any case and with nothing after it, to the next ## heading. 15 items, then a count of the rest; 150 characters each
4 DECISIONS Entries in shared tagged decision, or whose key starts with decision, changed within the last N × 24 hours (default N = 7); newest first, each with its local date. 8 items, 120 characters each
5 RECENT CHANGES All entries not printed in an earlier section (those an earlier section's item limit or status rule left out included), by timestamp, newest first, each with its local date and time. 10 items, 100 characters each

Sections 2 and 4 SHOULD leave out entries whose status is superseded.

Each item is an excerpt of a value: whitespace collapsed to single spaces; leading [Amended …] notes removed; cut at a word boundary within the bound, with ... appended when cut; a word that ends exactly at the bound is kept, and a first word longer than the bound is cut at it. The bound counts the excerpt alone, not the appended ... nor the key, date or store shown before it. Each item of sections 2, 4 and 5 names its key, after its date where it shows one, and its store when that is not shared, so the reader can fetch the full entry.

The bounds are tunable; the order, the selection rules and the boundedness are not.

5.3 Harness integration (informative)

Any harness that can run a shell command can use ctx. One of three patterns is enough; they combine.

A. Start hook. The harness runs a command when a session starts and adds its output to the agent's context. Configure it to run ctx start, only when the working directory is under the operator's projects, with a timeout, and with errors ignored, so that a broken context never blocks a session.

B. Instruction file. The harness loads a standing instruction file at every start (AGENTS.md, or the harness's own equivalent). The file holds a pointer, nothing more:

At the start of every session, run `ctx start` and follow the rules it
prints. Record what you learn with `ctx set` as you go. You are
<agent name>: sign your session summary and your commits with it.

C. Launcher. A small script runs ctx start, appends the operator's instruction, and starts the harness with the result as its opening prompt. With no instruction, a safe default is: "READ ONLY. Say in a few lines where the last session left off, then wait for my instruction."

In every pattern, the harness's environment SHOULD set CTX_AUTHOR to the agent's name (§9.2), and a harness's private memory, if it has one, SHOULD hold one item: a pointer to ctx.

An agent that sets ctx up for its operator follows §13.

6. Rules for every agent

The context root MUST contain RULES.md, which start prints first. It holds the rules every agent works by: how to use ctx and how a session runs. The operator MAY add to them. The operator's standing instructions and preferences ("ask one question at a time", "no new dependencies") go into the operator store instead, where the brief shows them at every start (§5.2) and where each can be amended or superseded on its own. The following four are the RECOMMENDED minimum:

- **ctx is the shared memory.** Project knowledge goes into ctx, where
  the operator and every agent can find it. An agent's private memory
  holds at most a pointer to ctx.
- **Write only through `ctx`.** Do not edit the store files directly:
  ctx keeps the lock, the timestamps and the status fields correct.
- **READ means read.** When the operator says READ or READ ONLY, change
  nothing (ctx, files, version control) until they say WRITE.
- **Sign your own work.** End each session with a one-page summary in
  `sessions/`, headed with your own name (`**Agent:** <name>`). Set
  `CTX_AUTHOR` to the same name. Commits carry your own co-author
  trailer.

A co-author trailer is a Co-Authored-By: line naming the agent, in the form its harness uses.

The rules name no harness and no model, so that one file serves every agent.

7. Session summaries

Location and name. Summaries live in <root>/sessions/, one file per session, named YYYY-MM-DD[suffix]_session.md. The date is the local date on which the session started. The first session of a day has no suffix; later ones take b, c, … z, then za, zb, … zz, then zza, and so on: the suffix is empty, or a letter b to z, or one or more z followed by a letter a to z (^[0-9]{4}-[0-9]{2}-[0-9]{2}([b-z]|z+[a-z])?_session\.md$). The newest summary is the last file name in byte order (the C locale), which this naming keeps equal to the order of writing. Only files named in this form are summaries; any other file in sessions/ is ignored.

Content. One page of Markdown:

# Session 2026-10-13

**Agent:** <name> · **Focus:** <one line>

## Done
- What was built, found or decided, each with the ctx keys that hold it.

## Decisions
- Each decision the operator made, in a line.

## Open
- Each item still open, restated in full.

## Commits
- Hashes and one line each, when the work is under version control.

## Open is REQUIRED: the brief reads its top-level bullets (lines starting - ), the first line of each, cut at 150 characters. So write each open item's first line to stand alone, in under 150 characters, and put details on the lines below it. The brief reads only the newest summary, so an open item carried over from an earlier session MUST be restated, not referred to.

A summary is written once, at the end of its session. Later corrections go into ctx, or into a later summary. The commit that adds a summary cannot list itself; ## Commits lists the session's other commits.

8. Storage and concurrency

8.1 Context root

The context root is the directory named by the environment variable CTX_HOME. When CTX_HOME is unset, the implementation chooses a default and MUST document it. Because implementations choose different defaults, every harness and launcher SHOULD set CTX_HOME, even to the default. The root MUST have this layout (the summaries are §7's):

<root>/
├── RULES.md
├── memory/
│   ├── operator.json
│   ├── shared.json
│   ├── agent.json
│   └── .lock
└── sessions/
    └── 2026-10-13_session.md

8.2 Store files

Each store MUST be one UTF-8 JSON file, memory/<store>.json, holding an object

{
  "type": "shared",
  "version": "1",
  "last_updated": "2026-10-13T09:41:07Z",
  "entries": {
    "<key>": { "value": "...", "source": "agent", "...": "..." }
  }
}

Derived data — counts, search indexes — MAY be cached anywhere, and MUST be rebuildable from the entries alone.

8.3 Atomic writes

A store file MUST be replaced atomically: write the new content to a temporary file with a unique name in the same directory, then rename it over the old file. A crash leaves the old file or the new one, never a partial one.

8.4 Locking

Every read-modify-write of a store MUST hold an exclusive lock that covers the whole context: one lock file (memory/.lock), locked with the platform's exclusive whole-file lock. On POSIX systems that is flock(2) with LOCK_EX, not fcntl or lockf record locks, which on Linux do not exclude a flock lock; on Windows, LockFileEx on the lock file. A process MAY take the lock re-entrantly. Any other program that writes the stores — a library API, a second implementation — MUST take the same lock. Reads MAY proceed without it, since writes are atomic.

8.5 Version control

Keeping the context root in version control is RECOMMENDED: it gives history, backup and an audit trail. The lock file and rebuildable indexes SHOULD be excluded.

9. Command line

9.1 Commands

The command line is RECOMMENDED in this form, so that the rules, the instruction files and the agents' habits carry over between implementations:

Command Operation
ctx start §5.1
ctx brief [--days N] [--changes N] §5.2
ctx get <key> [--store S] [--all] [-v] §4.1; -v shows every field
ctx set <key> <value…> [--store S] [--source X] [--basis B] [--scope C] [--tag T]… [--derivation D] §4.2
ctx amend <key> <note…> [--store S] §4.3
ctx supersede <key> --by <key> [--note N] [--store S] §4.3
ctx resolve <key> [--note N] [--store S] §4.3
ctx basis <key> <basis> [--store S] §4.4
ctx delete <key> [--store S] §4.5
ctx search [pattern] [--store S] [--tag T]… [--source X] [--status S] [--scope C] [--since T] [--limit N] [-v] §4.6
ctx list [--store S] [--tag T]… [--source X] [--status S] [--scope C] [--since T] [--keys-only] [-v] §4.6 (--limit MAY be offered)

The words of <value…> and <note…> are joined with single spaces. A joined value is stored as JSON (RFC 8259) when it is an object, an array, true, false, null, or an integer written without a sign other than -, without leading zeros, fraction or exponent, from -(2^53 - 1) to 2^53 - 1. Anything else is stored as the string it was: 1.10, 1.5, 007, 1e999, NaN and 9007199254740993 stay text, so every implementation stores the same value. An object or array whose numbers JSON cannot write back (1e999) is stored as a string too.

A value's JSON text, where this specification uses it (§3.6, §4.6), is its compact form: no whitespace between tokens, members in their stored order, and non-ASCII characters as themselves.

Options come before or after the value's words; an option between them is an error. Quote a value that holds a word starting with --, or put -- before it: -- ends the options. An unknown option is an error. The search pattern is one argument: quote a pattern of several words. An implementation MAY offer --json output on any command.

Exit status: 0 on success; non-zero when a key is not found, when an argument is invalid, or when a write fails.

9.2 Environment

Variable Meaning
CTX_HOME The context root (§8.1).
CTX_AUTHOR The writer's name, stamped as author on every entry it writes or marks. Set by each agent's harness; the operator MAY set it too. When set, source defaults to agent (§3.4).

10. Conformance levels

Core (REQUIRED): the data model (§3); get, set, amend, supersede, resolve, basis, delete, search and list (§4); start and brief (§5.1, §5.2); RULES.md (§6); reading session summaries (§7); CTX_HOME and CTX_AUTHOR (§9.2); atomic writes and locking (§8.3, §8.4).

Optional:

11. Security and privacy considerations

12. Rationale (informative)

13. Setting up ctx (for agents)

This section is informative: a procedure for an agent whose operator asked it to set up a local context from this document. The requirements it relies on are in §3 to §11.

13.1 What you deliver

  1. A ctx command on the operator's PATH that meets the core level (§10), with the recommended command line (§9.1) and store files (§8.1, §8.2). Appendix B tests that form.
  2. A context root holding RULES.md, memory/ and sessions/.
  3. Your harness wired so that every session starts from ctx start (§5.3).
  4. The acceptance test of Appendix B, passing.
  5. A first session summary (§7).

13.2 How to work while you set it up

13.3 Ask the operator

Ask one question at a time, and offer the default, so that the operator can answer "the default" to each:

  1. Where the context root goes. Default: ~/ctx. Any other place is named by CTX_HOME (§8.1).
  2. Your name, for CTX_AUTHOR and for your summaries (§3.4, §6). Default: your harness's or model's name.
  3. Which harnesses to wire, if the operator uses more than one. Default: the one you run in.
  4. Version control for the root (§8.5). Default: a local repository, with no remote. If the operator wants one and the machine has no git identity, also ask which name and address commits should carry.
  5. What the work is about now, in one paragraph, for current_focus (§3.2). Then standing instructions: how the operator wants agents to work (ask first, one question at a time, no new dependencies, and the like). They go into the operator store, not into RULES.md (§6). Default: none yet.

13.4 Build ctx

13.5 Create the context root

  1. Create the root, memory/ and sessions/, each with mode 0700. Files you write by hand (RULES.md, the summary, .gitignore) get mode 0600.

  2. Write RULES.md: the four rules of §6, and any rule the operator gives about how every agent uses ctx or runs a session. A heading at the top is fine.

  3. Record the operator's answers from §13.3, through ctx:

    export CTX_HOME=~/ctx CTX_AUTHOR="<your name>"
    ctx set current_focus "<the operator's paragraph>" --source operator --basis reported
    ctx set operator_preference_one_question "Ask one question at a time." \
      --store operator --source operator --basis reported --tag preference
  4. Under version control: initialise the repository in the root, exclude memory/.lock and any rebuildable index (§8.5), and commit. If the repository has no author identity, ask the operator which to use, or give one on each commit (git -c user.name=… -c user.email=… commit) rather than changing their configuration.

13.6 Wire the harness

Use the first pattern of §5.3 that your harness supports:

In every case, set CTX_AUTHOR and CTX_HOME in the harness's environment, CTX_HOME even when the root is the default (§8.1). If the harness keeps a private memory, give it one item: "Project memory is ctx: run ctx start."

To check the wiring, ask the operator to start a new session. The first thing the new session should have seen is the rules and the brief.

13.7 Prove it

  1. Save Appendix B as ctx_acceptance.py, outside the context root.
  2. Run python3 ctx_acceptance.py ctx, or give the path to your ctx. It works in a temporary root and never touches the real one.
  3. Every MUST has to pass. Fix each SHOULD that warns, or tell the operator why it stays.
  4. Run ctx start on the real root. It prints the rules, then a brief with the current focus and the operator's instructions.

13.8 Hand over

  1. Record the setup: ctx set result_ctx_setup_<YYYY_MM_DD> "<what you built, where, the test result>" --basis verified --tag result (verified, because you ran the test; the local date).
  2. Write the first session summary in sessions/ (§7), with an ## Open section.
  3. Under version control, commit the entries and the summary.
  4. Tell the operator in a few lines:
    • where the context root is;
    • the three commands they will use most: ctx brief, ctx get <key> and ctx set;
    • how each agent now starts from it (or that none does yet, if the wiring was left out);
    • that the stores are plain JSON and Markdown that they can read and keep under version control.

Appendix A. Examples (informative)

A.1 An entry

"result_upload_timeout_2026_10_13": {
  "value": "Uploads over 50 MB failed at 30 s: the proxy timeout. Raised to 300 s; a 200 MB upload now completes in 71 s.",
  "source": "agent",
  "author": "Agent One",
  "basis": "verified",
  "scope": "global",
  "tags": ["uploads", "result"],
  "derivation": "Reproduced with a 200 MB file before and after; see issue_upload_fails_2026_10_12.",
  "created": "2026-10-13T09:41:07Z",
  "timestamp": "2026-10-13T09:41:07Z"
}

A.2 A brief

=== ctx brief 2026-10-13 11:16 === (operator 9, shared 142, agent 3 entries; full text: ctx get <key> --store <store> -v)

CURRENT FOCUS
  Large uploads: find and fix why uploads over 50 MB fail, then add a progress bar.

STANDING INSTRUCTIONS (the operator's)
  - operator_preference_one_question [operator]: Ask one question at a time when several decisions are open.
  - no_new_dependencies [operator]: No new third-party dependencies without asking first.

OPEN (from sessions/2026-10-13_session.md)
  - The progress bar needs a design decision: per file or per batch.

DECISIONS (last 7 days)
  - 2026-10-12 decision_2026_10_12_proxy_timeout: Raise the proxy timeout rather than chunk uploads, for now.

RECENT CHANGES
  - 2026-10-13 09:41 result_upload_timeout_2026_10_13: Uploads over 50 MB failed at 30 s: the proxy timeout. Raised to 300 s; a 200 MB upload now completes ...
  - 2026-10-12 17:02 issue_upload_fails_2026_10_12 [resolved]: Uploads over 50 MB fail with a 504 after 30 s.

A.3 Recording as you go

export CTX_AUTHOR="Agent One"

# The operator said it: their knowledge, the agent's typing.
ctx set operator_preference_one_question \
  "Ask one question at a time when several decisions are open." \
  --store operator --source operator --basis reported --tag preference

# The agent checked it.
ctx set result_upload_timeout_2026_10_13 "Uploads over 50 MB ..." \
  --basis verified --tag uploads --tag result

# The issue is closed; the record stays.
ctx resolve issue_upload_fails_2026_10_12 \
  --note "Fixed: proxy timeout raised; see result_upload_timeout_2026_10_13"

Appendix B. Acceptance test (informative)

The script below checks the core level (§10) of a ctx that uses the recommended command line (§9.1) and store files (§8.1, §8.2). It runs the implementation in a temporary context root, reads the store files directly, and reports each check by section number. A failed MUST is a failure; a failed SHOULD is a warning. Python 3.8 or later, standard library only; it takes seconds.

#!/usr/bin/env python3
"""ctx acceptance test: the core checks of the ctx specification.

Based on the ctx specification by LangSyn, licensed under CC BY 4.0.

    python3 ctx_acceptance.py [path-to-ctx]

Runs a ctx that uses the recommended command line (section 9.1) and store
files (sections 8.1 and 8.2) in a temporary context root, and checks the
core level (section 10). It never touches a real context: CTX_HOME points
at the temporary root, and CTX_AUTHOR is set only where a check needs it.
A failed MUST is a failure; a failed SHOULD is a warning. Exit status 0
when every MUST passes. Python 3.8 or later, standard library only.
"""

import concurrent.futures
import hashlib
import json
import os
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import time

CTX = sys.argv[1] if len(sys.argv) > 1 else "ctx"
if os.sep in CTX:
    CTX = os.path.abspath(CTX)
ROOT = tempfile.mkdtemp(prefix="ctx-acceptance-")
TIMESTAMP = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?Z$")
results = {"pass": 0, "fail": [], "warn": []}


def ctx(*args, author=None):
    env = dict(os.environ, CTX_HOME=ROOT)
    env.pop("CTX_AUTHOR", None)
    if author is not None:
        env["CTX_AUTHOR"] = author
    return subprocess.run([CTX, *args], env=env, capture_output=True, text=True, timeout=120)


def entries(store="shared"):
    path = os.path.join(ROOT, "memory", store + ".json")
    if not os.path.exists(path):
        return {}
    with open(path, encoding="utf-8") as f:
        return json.load(f)["entries"]


def entry(key, store="shared"):
    return entries(store).get(key) or {}


def check(level, ok, what):
    if ok:
        results["pass"] += 1
    else:
        results["fail" if level == "MUST" else "warn"].append(what)
        print("%s  %s" % ("FAIL" if level == "MUST" else "warn", what))


def snapshot():
    out = {}
    for d, dirs, files in os.walk(ROOT):
        for name in dirs:
            out[os.path.join(d, name)] = "directory"
        for name in files:
            path = os.path.join(d, name)
            with open(path, "rb") as f:
                out[path] = hashlib.sha256(f.read()).hexdigest()
    return out


def write_store(store, new_entries):
    """Test set-up only, with no ctx running: entries written straight in."""
    path = os.path.join(ROOT, "memory", store + ".json")
    data = {"type": store, "version": "1", "last_updated": "2026-01-01T00:00:00Z", "entries": {}}
    if os.path.exists(path):
        with open(path, encoding="utf-8") as f:
            data = json.load(f)
    data["entries"].update(new_entries)
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)


def make_entry(value, source="agent", tags=(), when="2026-01-01T00:00:00Z", **extra):
    e = {"value": value, "source": source, "basis": "inferred", "scope": "global",
         "tags": list(tags), "created": when, "timestamp": when}
    e.update(extra)
    return e


def main():
    # 5.1: start on an empty context succeeds, says so, and writes nothing
    before = snapshot()
    r = ctx("start")
    check("MUST", r.returncode == 0, "start on an empty context exits 0 (5.1)")
    check("MUST", snapshot() == before, "start writes nothing (5.1)")

    # 4.2, 3.3, 3.4, 3.5: a new entry and its defaults
    r = ctx("set", "k_plain", "hello", "world")
    check("MUST", r.returncode == 0, "set exits 0 (4.2)")
    e = entry("k_plain")
    check("MUST", e.get("value") == "hello world", "the words of a value are joined with single spaces (9.1)")
    check("MUST", e.get("source") == "operator", "without CTX_AUTHOR the source defaults to operator (3.4)")
    check("MUST", e.get("basis") == "inferred", "a new entry's basis defaults to inferred (3.5)")
    check("MUST", e.get("scope") == "global", "the scope defaults to global (3.7)")
    check("MUST", e.get("tags") == [], "tags default to none (4.2)")
    check("MUST", TIMESTAMP.match(str(e.get("created", ""))) is not None, "created is RFC 3339 UTC with Z (3.3)")
    check("MUST", TIMESTAMP.match(str(e.get("timestamp", ""))) is not None, "timestamp is RFC 3339 UTC with Z (3.3)")
    check("MUST", "k_plain" not in entries("operator"), "the store defaults to shared (3.1)")
    check("SHOULD", "author" not in e, "no author without CTX_AUTHOR (3.4)")
    ctx("set", "k_blank", "x", author=" ")
    e = entry("k_blank")
    check("MUST", e.get("source") == "operator" and "author" not in e, "a blank CTX_AUTHOR counts as unset (3.4)")

    ctx("set", "k_agent", "found it", author="Agent One")
    e = entry("k_agent")
    check("MUST", e.get("source") == "agent", "with CTX_AUTHOR the source defaults to agent (3.4)")
    check("MUST", e.get("author") == "Agent One", "CTX_AUTHOR is stamped as author (3.4)")
    ctx("set", "k_said", "the operator said so", "--source", "operator", author="Agent One")
    check("MUST", entry("k_said").get("source") == "operator", "an explicit source wins (3.4)")

    # 9.1: values that parse as JSON are stored as JSON
    ctx("set", "k_number", "42")
    ctx("set", "k_object", '{"a": 1, "needle": "xyzzy"}')
    check("MUST", entry("k_number").get("value") == 42, "a value that parses as JSON is stored as JSON: 42 (9.1)")
    check("MUST", entry("k_object").get("value") == {"a": 1, "needle": "xyzzy"}, "an object is stored as an object (9.1)")
    ctx("set", "k_nan", "NaN")
    check("MUST", entry("k_nan").get("value") == "NaN", "NaN is not JSON: it is stored as a string (9.1)")
    ctx("set", "k_huge", "1e999")
    check("MUST", entry("k_huge").get("value") == "1e999", "a number JSON cannot write back is stored as a string (9.1)")
    for text in ("1.10", "1.5", "007", "9007199254740993"):
        ctx("set", "k_text_number", text)
        check("MUST", entry("k_text_number").get("value") == text, "%s stays text (9.1)" % text)
    ctx("set", "k_true", "true")
    ctx("set", "k_big_int", "9007199254740991")
    check("MUST", entry("k_true").get("value") is True and entry("k_big_int").get("value") == 9007199254740991,
          "true and an integer up to 2^53 - 1 are stored as JSON (9.1)")
    with open(os.path.join(ROOT, "memory", "shared.json"), encoding="utf-8") as f:
        store_file = json.load(f)
    check("MUST", store_file.get("type") == "shared" and store_file.get("version") == "1",
          "a store file names its type, and version 1 (8.2)")
    with open(os.path.join(ROOT, "memory", "shared.json"), encoding="utf-8") as f:
        try:
            json.load(f, parse_constant=lambda c: (_ for _ in ()).throw(ValueError(c)))
            strict = True
        except ValueError:
            strict = False
    check("MUST", strict, "the store file is strict JSON (8.2, 9.1)")

    # 4.1, 4.5: get, delete, and missing keys
    r = ctx("get", "k_plain")
    check("MUST", r.returncode == 0 and "hello world" in r.stdout, "get prints the entry (4.1)")
    check("MUST", ctx("get", "no_such_key").returncode != 0, "get of a missing key exits non-zero (4)")
    check("MUST", ctx("delete", "no_such_key").returncode != 0, "delete of a missing key exits non-zero (4)")
    ctx("set", "k_doomed", "x")
    check("MUST", ctx("delete", "k_doomed").returncode == 0 and "k_doomed" not in entries(), "delete removes the entry (4.5)")
    check("MUST", ctx("set", "k_bad", "x", "--basis", "maybe").returncode != 0, "an invalid argument exits non-zero (9.1)")
    check("MUST", ctx("set", "k_bad", "x", "--no-such-option").returncode != 0, "an unknown option is an error (9.1)")
    ctx("set", "k_after", "one", "two", "--tag", "late")
    check("MUST", entry("k_after").get("value") == "one two" and entry("k_after").get("tags") == ["late"],
          "options may follow the value's words (9.1)")
    check("MUST", ctx("set", "k_between", "one", "--tag", "mid", "two").returncode != 0,
          "an option between the value's words is an error (9.1)")
    ctx("set", "k_dashes", "--", "--literal")
    check("MUST", entry("k_dashes").get("value") == "--literal", "-- ends the options (9.1)")

    ctx("set", "k_both", "in operator", "--store", "operator")
    ctx("set", "k_both", "in shared")
    r = ctx("get", "k_both", "--all")
    check("MUST", "in operator" in r.stdout and "in shared" in r.stdout, "get --all returns the key from every store (4.1)")

    # 4.2: an update keeps created, basis, unknown fields and tags
    ctx("set", "k_up", "first", "--basis", "verified", "--tag", "alpha", "--tag", "beta", "--scope", "session")
    first = entry("k_up")
    check("MUST", first.get("basis") == "verified" and first.get("scope") == "session", "basis and scope are stored as given (4.2)")
    time.sleep(1.1)
    raw = entries()
    raw["k_up"]["x_from_elsewhere"] = {"kept": True}
    write_store("shared", {"k_up": raw["k_up"]})
    ctx("set", "k_up", "second")
    e = entry("k_up")
    check("MUST", e.get("value") == "second", "an update replaces the value (4.2)")
    check("MUST", e.get("created") == first.get("created"), "an update keeps created (4.2)")
    check("MUST", e.get("timestamp") != first.get("timestamp"), "an update sets timestamp (4.2)")
    check("MUST", e.get("basis") == "verified", "an update without a basis keeps the basis (3.5)")
    check("MUST", e.get("x_from_elsewhere") == {"kept": True}, "an update keeps fields it does not know (3.3)")
    check("MUST", sorted(e.get("tags", [])) == ["alpha", "beta"], "an update without tags keeps the tags (4.2)")
    ctx("set", "k_up", "third", "--tag", "gamma")
    check("MUST", entry("k_up").get("tags") == ["gamma"], "given tags replace the old ones (4.2)")
    check("MUST", entry("k_up").get("scope") == "session", "an update without a scope keeps the scope (4.2)")
    ctx("set", "k_instr", "Ask first.", "--store", "operator", "--source", "operator", "--derivation", "said today",
        author="Agent One")
    ctx("set", "k_instr", "Ask first, always.", "--store", "operator", author="Agent Two")
    e = entry("k_instr", "operator")
    check("MUST", e.get("source") == "agent", "an update without a source takes the default again (4.2)")
    check("MUST", not e.get("derivation"), "an update without a derivation removes it (4.2)")
    check("MUST", e.get("author") == "Agent Two", "an update stamps the new author (4.2)")
    ctx("set", "k_instr", "Ask first, always!", "--store", "operator")
    check("MUST", "author" not in entry("k_instr", "operator"), "an update without CTX_AUTHOR removes the author (4.2)")

    # 3.6, 4.3, 4.4: statuses and the basis
    ctx("set", "k_amend", "the port is 8080")
    r = ctx("amend", "k_amend", "it is 8443", author="Agent One")
    e = entry("k_amend")
    today = time.strftime("%Y-%m-%d", time.gmtime())
    yesterday = time.strftime("%Y-%m-%d", time.gmtime(time.time() - 86400))
    check("MUST", r.returncode == 0 and e.get("status") == "amended", "amend sets status amended (4.3)")
    check("MUST", re.match(r"^\[Amended (%s|%s|%s): it is 8443\]\n" % (today, yesterday, time.strftime("%Y-%m-%d")),
                           str(e.get("value"))) is not None, "amend prepends [Amended YYYY-MM-DD: note] and a newline (3.6)")
    check("MUST", TIMESTAMP.match(str(e.get("status_timestamp", ""))) is not None, "a status sets status_timestamp (3.6)")
    check("MUST", e.get("author") == "Agent One", "a status sets author when one is known (3.6)")
    check("MUST", "amended" in ctx("get", "k_amend").stdout, "get shows the status (3.6)")
    ctx("set", "k_amend", "the port is 8443")
    check("MUST", "status" not in entry("k_amend"), "a new value clears the status (3.6)")

    ctx("set", "k_old", "use A")
    ctx("set", "k_new", "use B")
    ctx("supersede", "k_old", "--by", "k_new", "--note", "B is faster")
    e = entry("k_old")
    check("MUST", e.get("status") == "superseded" and e.get("superseded_by") == "k_new", "supersede sets the status and superseded_by (3.6)")
    check("MUST", e.get("value") == "use A", "supersede keeps the value (3.6)")
    out = ctx("get", "k_old").stdout
    check("MUST", "superseded" in out and "k_new" in out, "get shows superseded and the replacement key (3.6)")
    r = ctx("supersede", "k_new", "--by", "no_such_replacement")
    said = (r.stderr + r.stdout).lower()
    check("SHOULD", any(w in said for w in ("warn", "not exist", "does not", "missing", "no such", "unknown")),
          "supersede warns when --by does not exist (4.3)")

    ctx("set", "k_issue", "uploads fail")
    ctx("resolve", "k_issue", "--note", "fixed")
    e = entry("k_issue")
    check("MUST", e.get("status") == "resolved" and e.get("value") == "uploads fail", "resolve sets the status and keeps the value (3.6)")
    ctx("resolve", "k_old", "--note", "B is gone too")
    e = entry("k_old")
    check("MUST", e.get("status") == "resolved" and "superseded_by" not in e and e.get("status_note") == "B is gone too",
          "a new status replaces the old one whole (3.6)")
    ctx("set", "k_amend_obj", '{"port": 8080}')
    ctx("amend", "k_amend_obj", "it is 8443")
    value = entry("k_amend_obj").get("value")
    try:
        rest = json.loads(value.split("\n", 1)[1]) if isinstance(value, str) else None
    except ValueError:
        rest = None
    check("MUST", isinstance(value, str) and value.startswith("[Amended ") and rest == {"port": 8080},
          "amending a non-string value prepends the note to its JSON text (3.6)")

    ctx("set", "k_basis", "something")
    t0 = entry("k_basis").get("timestamp")
    time.sleep(1.1)
    ctx("basis", "k_basis", "verified", author="Agent Three")
    e = entry("k_basis")
    check("MUST", e.get("basis") == "verified" and e.get("timestamp") == t0, "basis changes the basis, not the timestamp (4.4)")
    check("MUST", e.get("author") == "Agent Three", "basis sets the author when one is known (4.4)")

    # 4.6: search and list
    r = ctx("search", "HELLO")
    check("MUST", "k_plain" in r.stdout, "search matches the value case-insensitively (4.6)")
    check("MUST", "shared" in r.stdout, "each result names its store (4.6)")
    check("SHOULD", "k_object" in ctx("search", "xyzzy").stdout, "search matches a non-string value on its JSON text (4.6)")
    check("SHOULD", "k_object" in ctx("search", '"needle":"xyzzy"').stdout, "a value's JSON text is compact (9.1)")
    check("MUST", "k_both" in ctx("search", "in operator").stdout, "search covers every store by default (4.6)")
    ctx("set", "k_t1", "tagged", "--tag", "t1")
    ctx("set", "k_t2", "tagged", "--tag", "t2")
    out = ctx("list", "--tag", "t1").stdout
    check("MUST", "k_t1" in out and "k_t2" not in out, "list --tag filters (4.6)")
    out = ctx("list", "--source", "agent").stdout
    check("MUST", "k_agent" in out and "k_plain" not in out, "list --source filters (4.6)")
    out = ctx("list", "--store", "operator").stdout
    check("MUST", "k_both" in out and "k_plain" not in out, "list --store filters (4.6)")
    check("MUST", "k_plain" in ctx("search", "").stdout, "an empty pattern matches every entry (4.6)")
    out = ctx("list", "--scope", "session").stdout
    check("MUST", "k_up" in out and "k_plain" not in out, "list --scope filters (4.6)")
    out = ctx("list", "--status", "resolved").stdout
    check("MUST", "k_issue" in out and "k_amend" not in out, "list --status filters (4.6)")
    write_store("shared", {"k_fraction": make_entry("fraction", when="2026-01-01T00:00:07.5Z")})
    out = ctx("search", "fraction", "--since", "2026-01-01T00:00:07Z").stdout
    check("MUST", "k_fraction" in out, "since compares points in time, not text: 07.5Z is after 07Z (4.6)")
    out = ctx("search", "fraction", "--since", "2026-01-01").stdout
    check("MUST", "k_fraction" in out, "since takes a date (4.6)")
    cut = entry("k_t2").get("timestamp")
    for command in ("search", "list"):
        out = ctx(command, *(["tagged"] if command == "search" else []), "--since", cut).stdout
        check("MUST", "k_t2" in out and "k_plain" not in out, "%s --since filters (4.6)" % command)

    # 11: a value that looks like a credential
    r = ctx("set", "k_leak", "AKIA" + "ABCDEFGHIJKLMNOP")
    check("SHOULD", r.returncode != 0 or r.stderr.strip() != "", "a value that looks like a credential is warned about (11)")
    if "k_leak" in entries():
        ctx("delete", "k_leak")

    # 5.1, 5.2, 7: the start payload, the brief, the newest summary
    with open(os.path.join(ROOT, "RULES.md"), "w", encoding="utf-8") as f:
        f.write("- RULE-MARKER-7f3a: write only through ctx.\n")
    os.makedirs(os.path.join(ROOT, "sessions"), exist_ok=True)
    # Written out of order, the newest the smallest: only the file name
    # in byte order tells which is newest (7)
    summaries = [
        ("2026-10-13za_session.md", "NEWEST-OPEN-ITEM", 0),
        ("2026-10-13b_session.md", "THIRD-OPEN", 200),
        ("2026-10-12_session.md", "OLD-OPEN-ITEM", 900),
        ("2026-10-13z_session.md", "FOURTH-OPEN", 400),
        ("2026-10-13_session.md", "SECOND-OPEN", 100),
    ]
    for name, item, padding in summaries:
        with open(os.path.join(ROOT, "sessions", name), "w", encoding="utf-8") as f:
            f.write("# Session\n\n**Agent:** Agent One - SUMMARY-%s\n\n## Done\n- work %s\n\n## Open\n- %s\n"
                    % (name, "x" * padding, item))
        time.sleep(0.05)
    with open(os.path.join(ROOT, "sessions", "2026-10-13za_session.md"), "a", encoding="utf-8") as f:
        f.write("\n## Open items\n- NOT-THE-OPEN-SECTION\n\n## Open\n- SECOND-OPEN-SECTION\n")
    for name in ("README.md", "notes_session.md", "zz_session.md", "2026-10-13a_session.md"):
        with open(os.path.join(ROOT, "sessions", name), "w", encoding="utf-8") as f:
            f.write("# Not a summary\n\n## Open\n- STRAY-OPEN-ITEM\n")
    now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    write_store("shared", {
        "current_focus": make_entry("FOCUS-MARKER-91c2: large uploads.", source="operator", when=now),
        "decision_pick_b": make_entry("DECISION-MARKER-55d0: B over A.", source="operator", tags=["decision"], when=now),
    })
    write_store("operator", {
        "pref_one_question": make_entry("DIRECTIVE-MARKER-0b6e: one question at a time.", source="operator",
                                        tags=["preference"], when=now),
        "pref_old": make_entry("OLD-DIRECTIVE-MARKER: two questions.", source="operator", tags=["preference"],
                               when=now, status="superseded", superseded_by="pref_one_question",
                               status_timestamp=now),
    })
    before = snapshot()
    r = ctx("start")
    out = r.stdout
    check("MUST", r.returncode == 0, "start exits 0 (5.1)")
    check("MUST", snapshot() == before, "start writes nothing (5.1)")
    marks = [out.find(m) for m in ("RULE-MARKER-7f3a", "FOCUS-MARKER-91c2", "pref_one_question",
                                    "NEWEST-OPEN-ITEM", "decision_pick_b", "SUMMARY-2026-10-13za_session.md")]
    check("MUST", all(m >= 0 for m in marks), "start prints the rules, the brief's sections and the newest summary (5.1)")
    check("MUST", marks == sorted(marks), "in order: rules, focus, instructions, open, decisions, summary (5.1, 5.2)")
    check("MUST", "OLD-OPEN-ITEM" not in out and "SUMMARY-2026-10-13z_session.md" not in out,
          "only the newest summary, by file name in byte order (7)")
    check("MUST", "STRAY-OPEN-ITEM" not in out, "files not named as summaries are ignored (7)")
    brief = ctx("brief").stdout
    check("MUST", "NOT-THE-OPEN-SECTION" not in brief and "SECOND-OPEN-SECTION" not in brief,
          "the Open section is the first headed exactly ## Open (5.2)")
    brief = ctx("brief").stdout
    old = brief.find("OLD-DIRECTIVE-MARKER")
    check("SHOULD", old < 0 or old > brief.find("NEWEST-OPEN-ITEM"),
          "the brief's standing instructions leave superseded entries out (5.2)")

    # 5.2: the brief is bounded
    write_store("shared", {"bulk_%05d" % i: make_entry("bulk value %d " % i + "word " * 40) for i in range(300)})
    small = len(ctx("brief").stdout)
    write_store("shared", {"bulk_%05d" % i: make_entry("bulk value %d " % i + "word " * 40) for i in range(300, 3000)})
    large = len(ctx("brief").stdout)
    check("MUST", large - small < 200, "the brief does not grow with the store: %d then %d characters (5.2)" % (small, large))
    now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    write_store("shared", {"decision_bulk_%03d" % i: make_entry("bulk decision %d " % i + "word " * 30, source="operator",
                                                                tags=["decision"], when=now) for i in range(150)})
    write_store("operator", {"pref_bulk_%03d" % i: make_entry("bulk instruction %d " % i + "word " * 30, source="operator",
                                                             tags=["directive"], when=now) for i in range(150)})
    flooded = len(ctx("brief").stdout)
    write_store("shared", {"decision_bulk_%03d" % i: make_entry("bulk decision %d " % i + "word " * 30, source="operator",
                                                                tags=["decision"], when=now) for i in range(150, 600)})
    write_store("operator", {"pref_bulk_%03d" % i: make_entry("bulk instruction %d " % i + "word " * 30, source="operator",
                                                             tags=["directive"], when=now) for i in range(150, 600)})
    flooded_more = len(ctx("brief").stdout)
    check("MUST", flooded_more - flooded < 200,
          "instructions and decisions stay bounded: %d then %d characters (5.2)" % (flooded, flooded_more))

    # 8.3, 8.4: concurrent writers lose nothing and leave a whole file
    def writer(name):
        for i in range(30):
            ctx("set", "conc_%s_%02d" % (name, i), "value %d" % i, author=name)
    with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
        list(pool.map(writer, ["w1", "w2", "w3"]))
    keys = [k for k in entries() if k.startswith("conc_")]
    check("MUST", len(keys) == 90, "three concurrent writers: %d of 90 entries kept (8.4)" % len(keys))
    leftovers = [n for n in os.listdir(os.path.join(ROOT, "memory")) if ".tmp" in n or n.endswith("~")]
    check("SHOULD", not leftovers, "no temporary files left in memory/: %s (8.3)" % (leftovers or "none"))

    # 8.4: the lock is flock(2) on memory/.lock, so a writer waits for it
    try:
        import fcntl
    except ImportError:
        fcntl = None
    if fcntl is not None:
        with open(os.path.join(ROOT, "memory", ".lock"), "a") as held:
            fcntl.flock(held, fcntl.LOCK_EX)
            env = dict(os.environ, CTX_HOME=ROOT)
            env.pop("CTX_AUTHOR", None)
            waiting = subprocess.Popen([CTX, "set", "k_locked", "x"], env=env,
                                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            time.sleep(1.5)
            blocked = waiting.poll() is None and "k_locked" not in entries()
            fcntl.flock(held, fcntl.LOCK_UN)
        waiting.wait(timeout=60)
        check("MUST", blocked and "k_locked" in entries(),
              "a write waits for an flock on memory/.lock held by another program (8.4)")

    # 11: owner-only permissions
    mode = stat.S_IMODE(os.stat(os.path.join(ROOT, "memory", "shared.json")).st_mode)
    check("SHOULD", mode & 0o077 == 0, "store files readable by their owner only: mode %o (11)" % mode)

    print("\n%d passed, %d failed, %d warnings" % (results["pass"], len(results["fail"]), len(results["warn"])))
    return 1 if results["fail"] else 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    finally:
        shutil.rmtree(ROOT, ignore_errors=True)

License

This specification is licensed under the Creative Commons Attribution 4.0 International License (CC BY 4.0). You may copy, adapt and build on it, for any purpose, including commercially, provided you give credit to LangSyn, link to the license and say whether you made changes. For example:

Based on the ctx specification by LangSyn, licensed under CC BY 4.0.

Implementing ctx from this specification needs no license.

Support

ctx is specified, not shipped: build your own. If this document saved you time, you can buy LangSyn a coffee at https://buymeacoffee.com/langsyn.

Revision history

Version Date Change
draft 1 2026-10-13 First draft.
draft 2 2026-10-15 For agents too: a pointer at the top; §13, setting up ctx (what to ask, what to build, wiring, proof, handover); Appendix B, an acceptance test. §5.3 points to §13. Stated where an agent building ctx from draft 1 had to guess: what an update keeps (§4.2), the writer's basis (§3.5), a status replacing the old one and amend's date and non-string values (§3.6), search, since and limit (§4.6), the brief's header, excerpts and open items (§5.2, A.2), rules against standing instructions (§6), store files (§8.2), strict JSON (§9.1), credential patterns (§11). After a second trial: since as points in time, the brief's store markers and open-item limit, a blank CTX_AUTHOR, numbers JSON cannot write back, which files are summaries, git identity.
1.0 2026-10-15 Stable: the text of draft 2, the promise of §1.4, and what a third trial build found two implementations could do differently: the file layout and store format are required, with a format version (§8.1, §8.2); the lock is flock on POSIX (§8.4); CTX_HOME is always set (§8.1, §13.6); which values are stored as JSON, and a value's JSON text (§9.1); tags kept on update (§4.2); store defaults (§3.1); the brief's selection and Open section (§5.2); summary names (§7); the command-line flags (§9.1); reads change nothing (§4.6); §13 builds against a throwaway root and proves before writing.