Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

### Simplify: one review date, no per-library freshness settings (#44)

- **`review_by` is the only review date.** Policy items no longer have a **Review date** field (or its "on or after the effective date" check), so the Policy starter has 10 fields. Migration `025_single_review_date.sql` copies the published Review date into `review_by` where none was set, and removes `review_date` from Policy items (working row and both published copies, without counting as a new publish) and from Policy library schemas. "Still accurate" and the Policy page can no longer disagree. Agents that still send `review_date` get "Unknown field(s): review_date".
- **Fixed review period.** Policy and Procedure libraries get publish day + 90 days on every publish. Other libraries get no automatic date; a publisher sets one per item on the item page. The **Freshness settings** form (review days, strict mode), `POST /libraries/:id/freshness`, `libraries.review_days`, `libraries.strict_freshness` and the `library_review_days` / `item_review_by_for` SQL functions are gone.
- **No strict mode.** Stale items are still answered, with the `warning` line naming who to ask; no refusals or `withheld` notes.
- Removed the unused `policyItemSchema` and `policyItemFields` (an older Policy form).
- Demo seed: Help Desk Runbooks (a General library) sets review-by dates per item.

### Simplify: shorter citations, fewer fields (#44)

- **Citations are one line:** a full item now ends with `cite_as`, plus `review_by` only when the item has a review-by date and `warning` only when it is past it. The separate `version`, `published_at`, `updated_at`, `published_by`, `url` and `stale` lines are gone: `cite_as` already carries the version, publish day, publisher and URL. JSON results (lists, `search_shelf`) carry the same keys in `citation`. On the demo VPN runbook the block went from 444 to about 230 characters (roughly 110 → 55 tokens per item).
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
MCPEncode keeps the facts your IT team's AI agents rely on (help desk runbooks, security policies, incident procedures, vendor lists) in **Libraries** of **Items** that people **Publish**. Agents connect to one Library with its **Connect link**, look Items up, and draft changes; with Review Lock (the default) nothing reaches **Readers** until a **Publisher** publishes it. One Node.js app plus PostgreSQL, self-hosted.

- **Citations with version.** Every answer names the published version, date, publisher and link, so you can see where an answer came from.
- **Freshness.** Items get a review-by date (90 days for Policy and Procedure by default). Agents relay a warning when an item is past review, and **strict mode** refuses stale items and points to the owner. A **Review queue** lists what is due, with one-click **Still accurate**.
- **Freshness.** Policy and Procedure items get a review-by date 90 days after each publish; publishers can set one on any item. Agents relay a warning naming the owner when an item is past review. A **Review queue** lists what is due, with one-click **Still accurate**.
- **Aliases.** "We got phished" finds the Incident Response runbook. Agents can suggest labels and aliases with `suggest_labels`; a person approves them like any other edit.

**Status:** Early (0.2). Issues welcome; please open one before a large pull request.
Expand Down Expand Up @@ -49,9 +49,9 @@ All data below is fictional demo content (`npm run seed-demo`). Left to right: t

| | |
| --- | --- |
| <img src="docs/assets/landing.png" alt="Libraries list with demo Libraries for IT policies, help desk runbooks, customer support, a biology course, product docs, HR and sales" width="440" /> | <img src="docs/assets/item.png" alt="A published Policy Item with owner, version, effective and review dates" width="440" /> |
| <img src="docs/assets/landing.png" alt="Libraries list with demo Libraries for IT policies, help desk runbooks, customer support, a biology course, product docs, HR and sales" width="440" /> | <img src="docs/assets/item.png" alt="A published Policy Item with owner, version and effective date" width="440" /> |
| <img src="docs/assets/pending-changes.png" alt="An Item with a Pending edit: banner says agents still see the published version, with a diff of what changed" width="440" /> | <img src="docs/assets/apps.png" alt="Settings, Apps: one hosted app waiting for approval and one approved" width="440" /> |
| <img src="docs/assets/review-queue.png" alt="Review queue for Help Desk Runbooks: Account Lockout overdue, Incident Response Runbook due next week, each with Still accurate; freshness settings with review period and strict mode" width="440" /> | <img src="docs/assets/label-suggestion.png" alt="An agent's suggested labels and aliases for the Incident Response Runbook, Pending for a person to Publish or Cancel" width="440" /> |
| <img src="docs/assets/review-queue.png" alt="Review queue for Help Desk Runbooks: Account Lockout overdue, Incident Response Runbook due next week, each with Still accurate" width="440" /> | <img src="docs/assets/label-suggestion.png" alt="An agent's suggested labels and aliases for the Incident Response Runbook, Pending for a person to Publish or Cancel" width="440" /> |
| <img src="docs/assets/item-freshness.png" alt="VPN Troubleshooting runbook with its review-by date, Still accurate button and aliases (remote access, office network, work from home)" width="440" /> | <img src="docs/assets/consent.png" alt="Allow access page asking whether an app may use this account for one Library" width="440" /> |

## Known limitations
Expand Down
14 changes: 6 additions & 8 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Each Connect link is one library. Tools offered depend on the caller's role in t
- **Lengths:** name 200, summary 160, labels 40 each and at most 20. A field without its own limit uses one default per type (text 200, longtext 8,000, markdown 50,000), the same in the tool's argument list and in the save check.
- **Labels** are stored trimmed, lower case and without duplicates, whether they come from an agent, the form or `suggest_labels`. Labels are free-form.
- **Summary** on a plain General library comes from the body; an agent that sends `summary` there is told to put the text in `body`.
- **Errors** list every problem in one message (with the library's field list), so an agent can fix them in one retry. The tool's argument list shows types, required fields and limits, but the save check enforces them, so a wrong type, a missing field and an unknown field all come back together. Review date versus effective date is compared only when both are real dates.
- **Errors** list every problem in one message (with the library's field list), so an agent can fix them in one retry. The tool's argument list shows types, required fields and limits, but the save check enforces them, so a wrong type, a missing field and an unknown field all come back together.
- **No match** from `list_items` (by `q` or `entity_key`) is a normal answer that says the item is not in the library, not a tool error.

**How `list_items q=` searches.**
Expand All @@ -94,14 +94,12 @@ JSON results (lists, `search_shelf`) carry the same three keys in a `citation` o
Never-published drafts say `citation: none`. Items published before this release start at version 1, with no recorded publisher.

**Freshness.**
- **Review-by date.** Each published item can have a review-by date, set on every publish (and on Review Unlock saves) to the publish day plus the library's review period.
- The period defaults to 90 days for Policy and Procedure libraries and is off for other types. Publishers change it under **Review → Freshness settings** (blank means the type default, 0 means off).
- Policy items use their own **Review date** field instead.
- A publisher can set or clear the date on the item page.
- The date lives outside the published copy (`items.review_by`). Changing it is not an edit: it does not go Pending, change the revision, or bump the citation version.
- **Review-by date.** Each published item can have one review-by date (`items.review_by`), the only review date there is.
- Policy and Procedure libraries: every publish (and Review Unlock save) sets it to the publish day plus 90 days.
- Other libraries: no automatic date. A publisher sets or clears it on the item page, and later publishes keep it.
- The date lives outside the published copy. Changing it is not an edit: it does not go Pending, change the revision, or bump the citation version.
- **Stale answers.** When the review-by date is before today (UTC), the citation adds a `warning` line naming who to ask (the Policy owner field, otherwise the publisher), which agents relay to the user.
- **Strict mode** (per library, off by default). Items past review are not given as answers. `open_item` and a single-match `list_items` return a refusal telling the user who should review it and where. Lists and `search_shelf` leave those items out and add a `withheld` note.
- **Review queue.** The library's **Review** tab lists published items due within 14 days or overdue. **Still accurate** re-confirms an item: review-by becomes today plus the period (90 days if the library has none), and who confirmed it and when are recorded and audit-logged (`ui.still_accurate`). Content and version stay the same.
- **Review queue.** The library's **Review** tab lists published items due within 14 days or overdue. **Still accurate** re-confirms an item: review-by becomes today plus 90 days, and who confirmed it and when are recorded and audit-logged (`ui.still_accurate`). Content and version stay the same.

**Edit conflicts.** Publisher results that return a full item (`open_item`, a single-match `list_items`, `create_item`, `update_item`) include `revision`, an opaque token. Pass it back as `expected_revision` on `update_item`: if anyone saved the item since, nothing is saved and the tool returns a conflict with `current_revision`, so the agent can re-read and reapply. Without `expected_revision` the last save wins, as before. The web edit form does the same check and shows the latest copy when it fails.

Expand Down
93 changes: 93 additions & 0 deletions migrations/025_single_review_date.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
-- One review date. Policy items used to state their own review_date field,
-- which was copied into items.review_by on publish; "Still accurate" then
-- moved only review_by, so the two could disagree. items.review_by is now the
-- only review date. The per-library review period and strict mode are gone:
-- Policy and Procedure libraries get publish day + 90 days, other libraries
-- get no automatic date (a publisher can still set one on the item page).

-- 1. Published Policy items with no review_by yet take the review_date of
-- their published copy. (Earlier publishes already copied it.)
UPDATE items i
SET review_by = (((i.published_snapshot::jsonb ->> 'details')::jsonb) ->> 'review_date')::date
FROM libraries l
WHERE l.id = i.library_id
AND l.template = 'policy'
AND i.review_by IS NULL
AND i.published_snapshot IS NOT NULL
AND pg_input_is_valid(i.published_snapshot, 'jsonb')
AND pg_input_is_valid(i.published_snapshot::jsonb ->> 'details', 'jsonb')
AND jsonb_typeof((i.published_snapshot::jsonb ->> 'details')::jsonb) = 'object'
AND (((i.published_snapshot::jsonb ->> 'details')::jsonb) ->> 'review_date') ~ '^\d{4}-\d{2}-\d{2}$'
AND pg_input_is_valid(((i.published_snapshot::jsonb ->> 'details')::jsonb) ->> 'review_date', 'date');

-- 2. Drop review_date from Policy items: the working row and both published
-- copies. Not a new publish, so the version and review_by triggers pause.
ALTER TABLE items DISABLE TRIGGER items_published_version;
ALTER TABLE items DISABLE TRIGGER items_review_by;

UPDATE items i
SET details = (i.details::jsonb - 'review_date')::text
FROM libraries l
WHERE l.id = i.library_id AND l.template = 'policy'
AND pg_input_is_valid(i.details, 'jsonb')
AND jsonb_typeof(i.details::jsonb) = 'object'
AND i.details::jsonb -> 'review_date' IS NOT NULL;

UPDATE items i
SET published_snapshot = jsonb_set(
i.published_snapshot::jsonb, '{details}',
to_jsonb((((i.published_snapshot::jsonb ->> 'details')::jsonb) - 'review_date')::text)
)::text
FROM libraries l
WHERE l.id = i.library_id AND l.template = 'policy'
AND pg_input_is_valid(i.published_snapshot, 'jsonb')
AND pg_input_is_valid(i.published_snapshot::jsonb ->> 'details', 'jsonb')
AND jsonb_typeof((i.published_snapshot::jsonb ->> 'details')::jsonb) = 'object'
AND ((i.published_snapshot::jsonb ->> 'details')::jsonb) -> 'review_date' IS NOT NULL;

UPDATE items i
SET previous_published_snapshot = jsonb_set(
i.previous_published_snapshot::jsonb, '{details}',
to_jsonb((((i.previous_published_snapshot::jsonb ->> 'details')::jsonb) - 'review_date')::text)
)::text
FROM libraries l
WHERE l.id = i.library_id AND l.template = 'policy'
AND pg_input_is_valid(i.previous_published_snapshot, 'jsonb')
AND pg_input_is_valid(i.previous_published_snapshot::jsonb ->> 'details', 'jsonb')
AND jsonb_typeof((i.previous_published_snapshot::jsonb ->> 'details')::jsonb) = 'object'
AND ((i.previous_published_snapshot::jsonb ->> 'details')::jsonb) -> 'review_date' IS NOT NULL;

ALTER TABLE items ENABLE TRIGGER items_published_version;
ALTER TABLE items ENABLE TRIGGER items_review_by;

-- 3. Policy library schemas lose the Review date field.
UPDATE libraries
SET schema = jsonb_set(schema, '{fields}', COALESCE((
SELECT jsonb_agg(field ORDER BY pos)
FROM jsonb_array_elements(schema -> 'fields') WITH ORDINALITY AS f(field, pos)
WHERE field ->> 'key' IS DISTINCT FROM 'review_date'
), '[]'::jsonb))
WHERE schema ->> 'starter' = 'policy'
AND jsonb_typeof(schema -> 'fields') = 'array'
AND EXISTS (SELECT 1 FROM jsonb_array_elements(schema -> 'fields') AS e(field) WHERE field ->> 'key' = 'review_date');

-- 4. On publish: publish day + 90 for Policy and Procedure libraries. Other
-- libraries keep whatever date a publisher set by hand (or none).
CREATE OR REPLACE FUNCTION items_set_review_by() RETURNS trigger LANGUAGE plpgsql AS '
BEGIN
IF NEW.published_snapshot IS NOT NULL AND (
TG_OP = ''INSERT'' OR NEW.published_snapshot IS DISTINCT FROM OLD.published_snapshot
) AND EXISTS (
SELECT 1 FROM libraries WHERE id = NEW.library_id AND template IN (''policy'', ''procedure'')
) THEN
NEW.review_by := (to_timestamp(NEW.updated_at / 1000.0) AT TIME ZONE ''UTC'')::date + 90;
END IF;
RETURN NEW;
END;
';

DROP FUNCTION IF EXISTS item_review_by_for(VARCHAR, TEXT, BIGINT);
DROP FUNCTION IF EXISTS library_review_days(VARCHAR);

ALTER TABLE libraries DROP COLUMN IF EXISTS review_days;
ALTER TABLE libraries DROP COLUMN IF EXISTS strict_freshness;
1 change: 1 addition & 0 deletions node_modules
2 changes: 0 additions & 2 deletions public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2355,8 +2355,6 @@ mark.diff-ins {
.inline-form { display: inline-flex; align-items: center; gap: 0.5rem; margin: 0; }
.freshness { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1rem; margin: 0.5rem 0; }
.freshness .meta { margin: 0; }
label.checkbox { display: flex; flex-direction: row; align-items: center; gap: 0.5rem; font-weight: 500; }
label.checkbox input { width: auto; margin: 0; }

/* Settings → Audit log */
.audit-filter { display: flex; gap: 0.75rem; align-items: end; margin: 0 0 1rem; }
Expand Down
20 changes: 10 additions & 10 deletions scripts/seed-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function policy(name: string, labels: string, summary: string, fields: Record<st
labels,
summary,
body: "",
fields: { owner: "Security Lead", approved_by: "IT Director", effective_date: "2026-07-01", review_date: "2027-07-01", ...fields },
fields: { owner: "Security Lead", approved_by: "IT Director", effective_date: "2026-07-01", ...fields },
};
}

Expand All @@ -46,8 +46,6 @@ type Lib = {
archived?: Doc;
pendingEdit?: { name: string; fields: Record<string, string> };
readerKey?: boolean;
/** Review every N days (freshness). Unset = type default. */
reviewDays?: number;
/** Review-by dates relative to today, so the Review queue has realistic entries. */
reviewIn?: Record<string, number>;
};
Expand Down Expand Up @@ -190,7 +188,6 @@ Actively exploited vulnerabilities are patched within 72 hours, outside the norm
archived: policy("Legacy VPN Policy", "policy, retired", "Retired. Replaced by the Remote Access Policy.", {
version: "1.2",
effective_date: "2023-01-15",
review_date: "2024-01-15",
purpose: "Rules for the old IPsec VPN.",
scope: "Remote staff, 2023.",
policy: "The IPsec VPN was decommissioned. See the Remote Access Policy.",
Expand All @@ -200,9 +197,15 @@ Actively exploited vulnerabilities are patched within 72 hours, outside the norm
{
name: "Help Desk Runbooks",
mode: "reviewed",
reviewDays: 90,
// Account Lockout slipped past its review; Incident Response is due next week.
reviewIn: { "Account Lockout": -9, "Incident Response Runbook": 6 },
// General library: review-by dates are set per item. Account Lockout
// slipped past its review; Incident Response is due next week.
reviewIn: {
"Account Lockout": -9,
"Incident Response Runbook": 6,
"VPN Troubleshooting": 90,
"Printer Queue Reset": 90,
"New Laptop Setup": 90,
},
items: [
{
name: "Incident Response Runbook",
Expand Down Expand Up @@ -447,9 +450,6 @@ for (const lib of LIBRARIES) {
await post("/libraries", { name: lib.name, template: lib.template ?? "general", write_mode: lib.mode }),
"libraries",
);
if (lib.reviewDays !== undefined) {
await post(`/libraries/${libraryId}/freshness`, { review_days: String(lib.reviewDays) });
}
const ids: Record<string, string> = {};
for (const doc of lib.items) ids[doc.name] = await addItem(libraryId, doc);
if (lib.mode === "reviewed") {
Expand Down
8 changes: 2 additions & 6 deletions src/db/citations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,14 @@ export type Citation = {
published_by: string | null;
url: string | null;
cite_as: string;
/** Review-by date of the published copy (YYYY-MM-DD), or null when the library does not track it. */
/** Review-by date of the published copy (YYYY-MM-DD), or null when it has none. */
review_by: string | null;
/** True when review_by is before today: the item may be out of date. */
stale: boolean;
/** Who to ask: the Policy owner field, else whoever published it. */
owner: string | null;
/** Short line for the agent to relay to the user. Only when stale. */
warning?: string;
/** Set (true) only when the library refuses stale items to agents. */
strict_freshness?: true;
/** Revision of the working row (what update_item replaces). Internal; not sent as part of the citation. */
revision: number;
};
Expand All @@ -40,7 +38,6 @@ type CitationRow = {
published_snapshot: string | null;
live_updated_at: number | string;
review_by: string | null;
strict_freshness: boolean | null;
details: string | null;
revision: number | string;
};
Expand Down Expand Up @@ -93,7 +90,7 @@ export async function citationsFor(
`SELECT live.id, live.name, l.name AS library_name, i.published_version,
a.username AS published_by_name, i.published_snapshot,
live.updated_at AS live_updated_at, i.review_by::text AS review_by,
l.strict_freshness, live.details, i.updated_at AS revision
live.details, i.updated_at AS revision
FROM items_live live
JOIN items i ON i.id = live.id
JOIN libraries l ON l.id = live.library_id
Expand Down Expand Up @@ -127,7 +124,6 @@ export async function citationsFor(
if (stale) {
citation.warning = `Past its review date (${row.review_by}); it may be out of date. Tell the user, and suggest checking with ${owner ?? "the library owner"}.`;
}
if (row.strict_freshness === true) citation.strict_freshness = true;
out.set(row.id, citation);
}
return out;
Expand Down
Loading
Loading