Commit graph

27 commits

Author SHA1 Message Date
Claude
156d2a6d43 Refresh pre-aggregations daily, not every ten minutes
lin001 is 2 vCPU with 3.8 GiB and no swap, shared with 27 other containers.
Cube was rebuilding three pre-aggregations over static fixture data on 10, 10
and 30 minute keys, and its own log shows what that cost:

    "Previous interval #19593 was not finished with 30000 interval"
    "Interval #19594 finished after 00:02:50"

A refresh taking 2m50s scheduled every 30 seconds, at interval #19594 -
overlapping and never catching up, for days. Every rebuild produced a
byte-identical result, because the fixtures do not change.

24 hours until imh makes the data genuinely live, at which point these get
tuned deliberately rather than left at a number that was never chosen. Cube
also suggests fewer partitions via rollup_lambda; that is a modelling change,
not a demo-day one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:53:51 +10:00
Claude
16c4bba3bd Mark cube.yokogawa.tech as the hostname nothing depends on
Kept, but labelled. ai-api reaches Cube as cube:4000 over the proxy network and
never by name, and the operator UI never touches Cube - the hostname exists so
an engineer can open the playground to hand-verify a measure, which a tunnel
does equally well. So it is the first one to drop if this host is being tidied
or the name is wanted elsewhere.

The note is in three places because whoever tidies this host will be reading
one of them and not this repo: above the block in ~/Caddyfile, inline on the
Authelia rule, and here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:44:47 +10:00
Claude
f4746cb39b Drop the auth check, and say what each hostname is for
Leaving the auth.yokogawa.tech note in verify.sh was a standing nudge towards a
fourth name, which is the opposite of what this host needs. Removed.

In its place, the thing that was actually missing: a list, where the Caddy
blocks live, of which hostname serves what and which one a control-room PC
needs. lin001 is shared and already carries a dozen *.yokogawa.tech services -
the cost of a name is not the DNS change, it is that nobody else can tell what
it is for. Notes that cube.yokogawa.tech is the one nothing depends on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:40:35 +10:00
Claude
b4c20324f5 Withdraw the auth.yokogawa.tech request - it blocks nothing yet
Raised as an item for Dan, which was premature. It blocks exactly one thing: a
demo from a control-room PC. Development and the Phase 8 eval both run from
outside the VNet, where auth.yokogawa.tech resolves normally, and the demo it
would block cannot happen until the Azure OpenAI account lands anyway.

The finding stays in verify.sh as information rather than a failure, so it is
in front of whoever prepares that demo instead of failing every run until then.

Also worth recording: this was inferred from lin001 resolving against 10.0.0.5,
not tested on cicore1. Same DC and same zone, so very likely identical, but it
has not been confirmed on the machine it concerns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:16:48 +10:00
Claude
8d4d731832 Raise the auth.yokogawa.tech pinpoint record, and check for it
The DC is authoritative for a yokogawa.tech zone holding pinpoint records only,
so from inside the VNet every name not in that zone is NXDOMAIN rather than
forwarded. ai and influx are in it. auth is not.

So an operator on cicore1 resolves ai.yokogawa.tech, reaches Caddy, is
redirected to https://auth.yokogawa.tech/?rd=... and cannot resolve it. The
assistant is reachable and cannot be signed into. Nothing caught this because
influx is the only other pinpointed hostname and LAN traffic to it uses the
bypassed API paths, which never touch the portal - ai.yokogawa.tech is the
first thing here a LAN client must interactively sign in to.

verify.sh now resolves auth.yokogawa.tech alongside the hostnames that redirect
to it, and fails rather than shrugging when it is missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:13:49 +10:00
Claude
7fdd8f55e7 Record the Caddy blocks and Authelia rules as applied
cube, api and ai joined the HTTPS_UserAccess two_factor rule in one Authelia
restart on 2026-08-27, backup configuration.yml.bak-ai-20260827. All four AI
hostnames now return 302 to the auth portal, and POST /ask on ai.yokogawa.tech
returns 303 to the portal rather than a 404 from ai-web, which is what proves
the route exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:07:54 +10:00
Claude
eb8cfaa787 Stop verify.sh reporting two things it cannot see
Both found running it on lin001 straight after the Caddy blocks went in.

The public-endpoint check called lf, cube and api unreachable. They are not:
the host resolves yokogawa.tech through the DC, which holds pinpoint records
only, and those three have none. Nothing is wrong with them and the check
cannot tell from here - so it now says so, and says to check from outside the
VNet, instead of printing a failure that means nothing.

403 now has its own message. It is what Caddy plus Authelia return for a
hostname with a site block and no access_control rule, which is the state
between applying the Caddy blocks and applying the Authelia rule - the one
case an operator is most likely to hit and least likely to recognise.

And the log check ran after the agent_ro test, which deliberately attempts a
forbidden INSERT that pg-ai logs. verify.sh was flagging an error line it had
just written itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:03:21 +10:00
Claude
1ce0bab98c Close request 2: the DNS records are in place
All three A records resolve to 20.211.144.151 and the DC carries the pinpoint
record ai.yokogawa.tech -> 10.0.0.17, verified from lin001. Kept in the file
as a record of what was asked for and what arrived, with the two things that
came out of it: api and cube have no pinpoint record and do not resolve inside
the VNet, and the names resolving is not the same as the services answering -
the Caddyfile blocks and Authelia rules are still to be applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:48:29 +10:00
Claude
76af3156fa Serve the operator page and /ask from one origin
api.yokogawa.tech has a public A record but no pinpoint record on the DC, so
it does not resolve from inside the VNet at all. The browser called it by
hostname, which means an operator on cicore1 would have loaded the page and
had every question fail on DNS - the exact gap Phase 7's gate exists to catch,
and one an engineer's laptop cannot see.

Caddy now routes /ask under ai.yokogawa.tech to ai-api, inside a route block
so import authelia still runs first: forward_auth sorts after handle in the
default directive order, and outside a route the handles would be terminal and
the gate would never run. Only /ask is routed - the Phase 9 publisher rule is
scoped to api.yokogawa.tech and a wider route here would leave it inert.

Also fixes the fallback it replaces. The build arg defaults to "", and
`?? "https://api.yokogawa.tech"` does not catch an empty string, so the
documented real-deployment build resolved the API base to "" and posted /ask
at ai-web, which 404s it. verify.sh and deploy.sh now check both the route and
whether the built bundle carries the hostname.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:48:22 +10:00
Claude
bff4dec49a Document the env keys that were missing from .env.example
This file says "every key, no values" at the top and was not that, which cost
real time twice on lin001 in one day.

  - CUBEJS_DB_* was absent entirely, while ai-compose.yml states that Cube's
    database settings come from api.env. Deploying Cube meant reconstructing
    what it needed from compose comments and db/003_roles.sql. Added, with the
    pg-ai.env names its credentials come from, and a note that Phase 4 turns
    the block into an mssql connection against imh.

    Also recorded NEGATIVELY: not CUBEJS_EXT_DB_*. Cube v1 refuses Postgres as
    an external pre-aggregation store, so someone reading the older compose
    file will otherwise try to supply keys for a setting that must not exist.

  - NO_LLM_STUB, which is new and defaults to false here for the same reason it
    defaults to false in config.py.

  - The Langfuse keys were listed but not explained, and every way of getting
    them wrong is SILENT:

      absent          -> _langfuse() returns None, every question untraced
      mismatched pair -> a client is built, Langfuse rejects it, and main.py
                         swallows the exception by design

    Neither logs anything, in an answer path that is deliberately built never
    to break on observability. The symptom is identical - no traces - so
    correcting one cause while the other is still present looks like no
    progress at all. That is exactly what happened: a placeholder secret was
    pasted alongside a public key from a different pair, and the fix looked
    like it had not worked.

    So the file now says: they are PROJECT keys from the UI, not the server's
    own SALT/NEXTAUTH_SECRET in langfuse.env; they must be a matched pair; the
    secret is shown once and stored hashed, so it cannot be read back; and
    traces must be confirmed as ARRIVING rather than inferred from config.

No secrets here, including the masked tail of a real key - the example suffix
is invented.

The Langfuse fix itself is not in this commit and cannot be: it was two lines
in ~/ai/api.env, which .gitignore excludes on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:49:35 +10:00
Claude
885d8e31e2 Add NO_LLM_STUB: the whole chain, working, without a model
Azure OpenAI is pending and imh is pending, so POST /ask could not return
anything at all - which left the entire chain either side of the model
unproven: the browser, the API, entity resolution, Cube, retrieval, the
contracts, the banners, the error paths. All of it is testable now, and waiting
for a key to find out whether it works is a choice to find out later.

NO_LLM_STUB=true substitutes the two steps that need a model and nothing else.

  - Classification: the caller supplies the class, from a dropdown in the UI.
    NOT a keyword classifier. A crude keyword classifier produces a PLAUSIBLE
    label, and a plausible wrong label is the exact failure this system exists
    to prevent - "how do I reset it" landing in Historical is how a synthesised
    procedure reaches an operator. Choosing by hand is honest about what is
    happening and drives each branch deliberately. apply_safety_rules() still
    runs over the result.

  - Prose: a fixed placeholder per class, in stub.py.

Everything else is the real path. This is possible because generate() already
kept the factual fields away from the model: rows, counts, citations, the
fixture flag and the class are attached from evidence, and only prose comes
from the generator. Splitting that into _generate_prose() and _assemble() makes
the seam explicit - the stub feeds _assemble() exactly as the model does, so
this is a fair test of the assembly path rather than a mock of it.

The contracts are the point. A stub payload goes through enforce_contract()
unchanged, and it FAILED first time on two classes: the "nothing found" wording
did not match the not-found detectors, so Reference and Procedural returned 422
rather than an uncited answer. That is the contract doing its job against text
no model wrote. Retries are pointless on deterministic output, and a 422 is a
real result here, not a stub bug.

Retrieval is lexical (retrieval.lexical_search), because embedding the question
needs the model. Kept beside search() and never called on the normal path, so
nobody reads a trace and mistakes a lexical hit for a semantic one. It matches
what the operator typed, not what they meant.

What it does not prove: whether the classifier would have labelled correctly -
a person did; whether retrieval finds the RIGHT chunk; and nothing about prose.
It also cannot fill prerequisites_verbatim - extracting them with a regex would
be the "synthesised from fragments" failure the Procedural contract forbids, so
the list is empty and the answer says so.

Every answer carries stub_mode: true in the contract, not decorated on by the
UI, and a banner beside the fixture banner. Same reasoning: an answer nobody
generated must not be indistinguishable from one that was.

Also here:
  - demo/ai-docs: three fabricated documents, numbered WRPS-DEMO-00x so header
    extraction is genuinely exercised against a number no real WRPS document
    can have. Their setpoints contradict tags.csv on purpose.
  - VITE_API_BASE build arg, for a tunnelled build before DNS exists. The
    tunnel origin is allowed in CORS only while NO_LLM_STUB is on, so it
    disappears with the flag. Proxying /api through ai-web's nginx would have
    been easier and was rejected: it creates a second route to the API that
    bypasses the api.yokogawa.tech Caddy block, where the Phase 9 publisher
    rule lives.

Verified on lin001 with no Azure key set at all: all five classes return 200
through the real UI in a browser, over an SSH tunnel, with citations from the
demo documents, real Cube numbers, and both banners showing.

Turning it off: NO_LLM_STUB=false in ~/ai/api.env, restart ai-api.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:24:25 +10:00
Claude
8343e4f0ac Make ingestion runnable without Docling or an embeddings account
Two blockers stopped Phase 3 being exercised at all before Azure OpenAI exists.
Both are lifted here, and neither weakens the header confirmation - a document
still becomes citable only after a person confirms its number, revision and
effective date.

  - parse_markdown() for .md, .markdown and .txt. Docling earns its place on a
    PDF: it recovers structure that is not in the bytes, and that structure is
    what makes section-boundary chunking possible instead of token-count
    splitting that cuts step sequences in half. A Markdown file already
    contains "## 2. Prerequisites". Running a document layout model over it
    buys nothing and costs the entire torch stack.

    It also unblocks the image. docling==2.15.1 does not resolve on
    python:3.12-slim: pip backtracks through docling_ibm_models releases for
    twenty minutes and exits 2. That is a real problem for Phase 3 and it is
    NOT fixed here - PDFs still need Docling and the ai-ingest image still will
    not build. Markdown ingestion runs from the ai-api image meanwhile.

    Page is 1 for these, because a Markdown file has no pages. A citation to
    one carries a section title and no meaningful page number, which is honest.

  - --no-embed inserts chunks with a NULL embedding and makes no API call, so
    the retrieval path can be exercised before an embeddings deployment exists.
    NULL, not a zero vector: a zero vector is a point in the space, it ranks
    against real queries, and it would surface as a plausible hit for anything
    asked. NULL returns no similarity at all.

    These chunks are invisible to vector search and findable only lexically.
    The flag warns about that four times on the way past, with the SQL to clear
    them, because a database half full of unembeddable chunks looks exactly
    like working retrieval right up until the question that matters returns
    nothing.

Before real ingestion: DELETE FROM doc_chunks WHERE embedding IS NULL;

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:24:00 +10:00
Claude
e281678328 Let the browser send the Authelia cookie: CORS must allow credentials
The UI calls the API with credentials: "include", because ai.yokogawa.tech and
api.yokogawa.tech are different origins and the Authelia session cookie has to
be attached explicitly. The CORS middleware never set allow_credentials, and a
browser refuses a credentialed cross-origin request unless the response says
Access-Control-Allow-Credentials: true. It fails at the preflight, so the real
request is never sent:

  Access to fetch at '.../ask' has been blocked by CORS policy: the value of
  the 'Access-Control-Allow-Credentials' header in the response is '' which
  must be 'true' when the request's credentials mode is 'include'.

Every question from the UI would have failed at Phase 7 with "Could not reach
the assistant" - the app's network-error branch, which says nothing about CORS
and points at the wrong layer entirely. The API is fine; curl against it passes,
because curl is not a browser and does not enforce this.

Found driving the built UI in a browser. It is not reachable by any test that
does not involve a browser, which is the useful part: the Phase 7 gate says an
operator reaches the UI and gets an answer end to end, and that gate is the
first thing that would have caught it - at the point where DNS, Caddy and
Authelia are all new too, and any of them a plausible suspect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:23:46 +10:00
Claude
e1499864d9 Pin the site timezone on every Cube query
Every Historical and Advisory answer stated a window in AEST and queried one
shifted by ten hours.

rolling_window() builds its boundary strings in SITE_TIMEZONE - that is the
whole point of it, and its docstring says so. metrics.run() then posted the
query to Cube with no timezone at all, and Cube defaults to UTC. So
"2026-08-14T15:22:13" meant 15:22 Sydney to the code that produced it and 15:22
UTC to the engine that ran it, and MetricResult.time_window reported
SITE_TIMEZONE from config rather than whatever the query actually used, so the
two could not disagree visibly.

Measured on the fixtures, same dateRange, one field changed:

    timezone UTC               8019 samples
    timezone Australia/Sydney  8619 samples

600 samples. One per minute, ten hours, exactly the offset.

Nothing about the answer looked wrong. The prose was right, the count was a
real count, the window description was correctly formatted and correctly named
AEST. It was only visible by reading the Cube query in the UI's "show working"
panel - which is an argument for that panel existing, and an argument for
looking at the thing in a browser rather than trusting curl against the API.

  - check_cube_query() now takes site_timezone and pins it onto the query, at
    the single point every Cube query passes through. Per-query-builder is the
    wrong place: "remember to set the timezone" is not a control, and this
    defect is what forgetting looks like. An explicit timezone already on the
    query is left alone.
  - time_window now reports capped["timezone"] - the timezone the query ran in,
    not the one it should have run in.

An unpinned timezone belongs in the same guardrail as an unpinned date range,
and for the same reason: both make an answer unreproducible. The difference is
that an unpinned date range is obvious in the query and an unpinned timezone
is invisible.

eval case H28 records it. Two unit tests: the timezone is pinned, and an
explicit one is not overridden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:23:27 +10:00
Claude
939cc2c83c Defer two Phase 5 findings to Phase 4, where imh can settle them
Both were found hand-verifying the measures on fixtures, and both are left
unfixed deliberately: fixing either now means guessing at what imh contains,
and a guess baked into the seed data or the models is harder to find later than
an open finding. Flagged at Phase 4, in the README, in the model files at the
point of use, and as eval cases that fail until they are settled.

(a) The wet well level tag does not join, and fails as "no records found".

    History is keyed PS_STN_WET_WELL_LEVEL, which process_values.yml hardcodes
    in seconds_above_high_level_alarm and seconds_above_lshh. tags.csv carries
    that name only as an ALIAS of LIT-101, so public.tags has no row with that
    tag_id and all 43,201 level rows - a third of the history, on the most
    important tag at this station - are unreachable from a tag-level lookup.
    Resolve "wet well" -> WW-101 -> LIT-101 -> filter history on LIT-101 ->
    zero rows -> "no records found".

    That is the failure mode worth being loudest about: it is the safety
    behaviour, produced by a key mismatch rather than by an absence of data,
    and indistinguishable from the real thing on screen. Filtering by
    equipment_id works, so whether a level question fails depends on which path
    the agent takes.

    The two flow tags use the opposite, self-consistent convention -
    PS_STN_INFLOW is a row in its own right and FIT-201 is marked NOT
    HISTORISED - so applying that to level is the likely fix. It still waits
    for the register map and for imh to say what CI Server historises the point
    as. Seed, hardcoded model names and 002_fixtures.sql change together.

(b) alarms.first_alarm and last_alarm return UTC, not SITE_TIMEZONE.

    Cube converts time dimensions to the query timezone; a min/max measure over
    a timestamp comes back unconverted. The Sydney day bucket for 2026-08-01
    returns 2026-07-31T20:00:35 - the right instant, ten hours and one calendar
    day out, beside a bucket label that IS in site time.

    This breaks "convert to SITE_TIMEZONE exactly once, in Cube", and the fix
    has to stay in Cube - the API compensating with timezone arithmetic is the
    thing that rule exists to prevent. Which fix is right depends on whether
    imh stores UTC or local, which is Phase 4 task 4. Until then these two must
    not be quoted to an operator as a clock time.

Phase 4's gate gains an item for each. Everything else verified in this pass -
alarms, operations, the equipment join, alias resolution and the Sydney/UTC day
boundaries - matched hand-written SQL exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:36:47 +10:00
Claude
d6b6f4f116 Fix two Cube measures that were invalid SQL
Hand-verifying the measures against the fixtures on lin001, per the Phase 5
gate. Two of them had never executed anywhere, and both failed outright rather
than returning a wrong number - which is the good version of this, but they
failed at the point an operator asks a question, not at review.

  - time_weighted_avg put LEAD() inside SUM(). Postgres rejects that flatly:
    "aggregate function calls cannot contain window function calls". The per
    sample duration now comes from the cube's source query, which changes
    sql_table to sql, and the measure just sums value * duration over duration.
    The last sample of each tag gets a NULL duration and SUM skips it, which is
    correct - how long it stood is not yet known.

    This is the measure that matters most later. On the regular one-minute
    fixtures it agrees with avg_value to thirteen decimal places
    (42.45934027777778 against 42.45934027777775), which proves it is wired up
    and proves nothing about imh, where the deadband makes samples irregular
    and the two will not agree. Re-verify it there.

  - p95_value applied the quality filter through a Cube measure `filters:`
    block, which lands outside the aggregate and cannot work on an ordered-set
    aggregate: "column process_values.quality must appear in the GROUP BY
    clause". Folded into the CASE inside PERCENTILE_CONT, whose NULL handling
    does the exclusion.

Also: the priority dimension said only SPILL and PUMP_TRIP are priority 1,
while the data has LEVEL_SIGNAL_FAULT at priority 1 too - correctly, losing the
level signal on a well that can spill is a priority 1 condition. That comment is
the line an engineer reads when checking a priority_1_count, so it disagreeing
with the data matters more than its length suggests.

eval cases H24 and H25 record the two failures, added before the fix.

Verified against hand-written SQL on the same pinned windows: p95_value
61.104999999999976 and time_weighted_avg 42.45934027777778 both match to the
floating point tail, as do sample_count, avg_value, max_value, min_value,
seconds_above_high_level_alarm (7680 = 128 samples x 60) and every measure in
alarms and operations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:36:20 +10:00
Claude
410062af78 Make Cube v1 run at all: Cube Store, refresh worker, healthcheck
Phase 5 deployed to lin001 for the first time, and the cube service as
committed could not answer a single query. Three separate faults, none of them
visible without deploying it.

  - CUBEJS_EXT_DB_TYPE: postgres is not supported in Cube v1. Cube Store is the
    only external pre-aggregation store, and it is also the default cache and
    queue driver, so naming Postgres failed EVERY query - not just
    pre-aggregated ones - with "It`s not possible to use Cube Store as
    queue/cache driver without using it as external". So cubestore is now a
    service: pinned in lockstep with cube, ai-internal only, no ports, data on
    /datadisk because it grows and / is 62 GB.

    CUBEJS_CACHE_AND_QUEUE_DRIVER: memory is NOT a way out. It does not fall
    back - it hangs /readyz and every query indefinitely, logging nothing at
    level warn. That cost longer to diagnose than the original error.

    This is a deviation from the build spec, which says pre-aggregations
    materialise into pg-ai schema cube_preagg. They cannot, on this version.
    cube_preagg and its grants in 003_roles.sql stay, unused, so that nothing
    else has to change if a later Cube restores Postgres as an external store.

  - CUBEJS_REFRESH_WORKER was never set, so nothing built the pre-aggregations.
    A query matching a rollup does not fall back to the source: it fails with
    "No pre-aggregation partitions were built yet". max_value, min_value and
    sample_count were dead on arrival while avg_value worked, which reads as a
    per-measure bug and is not one.

  - The healthcheck ran wget, which is not in the image (nor is curl). A
    perfectly healthy cube reported unhealthy on every deploy, training the
    reader to ignore the one signal that would show a real fault. It uses node,
    which the image does have.

Verified on lin001: cube healthy, no published host ports, ai-internal and
proxy only, and a query matching alarms_by_hour now returns external: true -
served from Cube Store rather than scanning the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:35:52 +10:00
Claude
0c0bd0ef6d Stop a bulk re-ingest resurrecting withdrawn documents
ingest_file() inserted every chunk with superseded = FALSE, so replacing a
document's chunks reset its withdrawal. One `ai-ingest --all` made every
superseded revision citable again - including the old revision of a procedure -
silently, and only after a bulk run, so a supersede survived exactly until the
next full ingest.

  - ingest_file() reads the existing state with superseded_state() before the
    delete and carries it through the insert. A withdrawn document is
    re-ingested as withdrawn, and logs that it did so.
  - --all skips withdrawn documents, so a bulk run does not spend an embeddings
    call on a document that will not be cited either way. --include-superseded
    overrides it; the chunks still come back withdrawn.
  - --restore DOC_NUMBER REVISION is the counterpart to --supersede, refusing
    while another revision of the same document is live. Without it the
    conservative default would be a dead end - mark_superseded() only ever sets
    TRUE, so there was no way back.
  - Rule 5 in the module docstring, beside the other four.

Corrects a claim in the Phase 9 design: 16.10 said moving the withdrawn file
out of /datadisk/ai-docs "is not tidying" because otherwise --all resurrects it.
That was true when written and is not now. The guard belongs in the ingest code,
because a rule that depends on somebody remembering to move a file is not a
rule. The move stays as archival housekeeping and is documented as such in
16.10, db/005_doc_actions.sql and README.md.

Phase 3's gate gains the proof: supersede a revision, run --all again, ask the
question that used to cite it.

Not executed anywhere - no Postgres or Docker on this machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:50:34 +10:00
Claude
3262f1a050 Give ingestion a role that can write
ai-ingest built its DSN from PGUSER/PGPASSWORD and takes its environment from
~/ai/api.env, where PGUSER=agent_ro - SELECT and nothing else, deliberately,
because it is what the answer path runs as. So

    docker compose -f ~/ai-compose.yml run --rm ai-ingest --all

connected as a role that cannot INSERT INTO doc_chunks, and Phase 3 was
unrunnable exactly as the README documents it. Nothing had reached Phase 3 yet,
so nobody had hit it.

The failure would also have landed at the worst possible moment: at the final
INSERT, after the Docling parse, after a person had typed the header
confirmations for every file, and after a billed embeddings call - with a
permission error naming no cause.

  - ingest_rw moves to 003_roles.sql, at Phase 1 with the other roles. It is
    not a Phase 9 concept; ingestion has needed a writing role since Phase 3
    and never had one. 004 keeps only its grants on the upload queue, and its
    idempotent role creation so it still applies to an older database.
  - ingest.py connects through INGEST_DB_USER / INGEST_DB_PASSWORD, falling
    back to PGUSER only for a local shell where one pair is set.
  - require_write_access() checks INSERT, UPDATE and DELETE on doc_chunks
    before anything is parsed or embedded, and fails with the fix in the
    message. Falling back to PGUSER cannot smuggle agent_ro past it.
  - Keyword connection parameters rather than a URL: a generated password
    containing @ or / breaks a DSN string silently.
  - A missing doc_chunks now says "apply 001_schema.sql" instead of raising
    UndefinedTable.

Phase 1's gate gains the check that would have caught this: ingest_rw must be
able to write doc_chunks. An ingestion role that cannot write is the same class
of failure as an API role that can - it just surfaces two phases later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:45:20 +10:00
Claude
830c050f72 Keep the Phase 9 compose additions inert until Phase 9
The design commit added ai-docs-worker with no profile and an inbox mount on
ai-api. Both take effect the moment anyone deploys, and neither has the code or
the directories behind it yet:

  - ai-compose.yml's own header says to run a bare `up -d`. That would start
    ai-docs-worker with entrypoint `python worker.py` - a file that does not
    exist - under restart: unless-stopped, so it crash-loops on a live shared
    host that also runs the demo PLC.
  - Docker creates a missing bind source as a ROOT-OWNED directory. ai-api does
    not run as root, so deploying the inbox mount before the directory exists
    gives the API an inbox it cannot write to, on the growing disk.

So: profiles: [worker] on the worker, matching the ai-ingest precedent, and the
ai-api volume block commented out with the install command beside it - the same
"add each piece at the phase that needs it" convention as caddy/ai-routes.caddy.
A bare `up -d` from main now starts exactly what it started before: pg-ai, cube,
ai-api, ai-web.

Both guards come off in the commit that adds worker.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:36:12 +10:00
Claude
189f528d47 Add the plain-language workflow map
A single-page explainer for people who will not read the build spec: how a
question becomes an answer, the four lanes and why they are separate, the four
tools the assistant may reach for, where the knowledge comes from, and what is
live against what is only built. Status snapshot as observed on the host,
21 August 2026.

Kept at the repository root beside the other narrative documents rather than in
docs/, which is gitignored and holds controlled plant documents. Listed in the
layout sections of README.md and the build spec so it is findable.

Note: it predates Phase 9 and so does not mention operator document upload,
withdrawal or pool curation. Its step table stops at 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:19:45 +10:00
Claude
98083cd8d6 Design Phase 9 - operator document management
Operators cannot add a document today: ingestion is CLI-only, needs a host
login and a TTY for confirm_header(), /datadisk/ai-docs is mounted read-only,
ai-api has no identity, and nothing in the stack has a role that can write
doc_chunks. This designs the way in, the way out, and control over what is in
the retrieval pool. Design and schema only - no router, worker or UI code yet.

Documents in (16.1-16.9, db/004):
  upload -> pre-scan -> review -> approve -> published, with the header
  confirmation moved from a terminal prompt to a review screen and recorded
  rather than discarded. A CHECK constraint refuses an approved row without a
  confirmed number, revision and effective date, so an API bug cannot skip it.
  Three roles: agent_ro unchanged, uploads_rw writes the queue only, ingest_rw
  writes doc_chunks and has no HTTP surface.

Documents out (16.10-16.11, db/005):
  --supersede needs a revision to keep, so a cancelled procedure cannot be
  withdrawn at all. Adds withdraw (immediate, reversible, audited), restore
  (refused while another revision is live) and purge (off by default). A
  column grant plus a trigger let the web-facing role make a document less
  citable and never more.

The pool (16.13-16.15, db/006):
  pool_enabled, orthogonal to superseded: one is a claim about the document,
  the other about the corpus. Retrieval requires both, so re-enabling a
  withdrawn document does not make it citable. Named profiles and a
  per-request override let a demo trim the corpus without mutating state on a
  shared live host, and every reduced-pool answer carries a banner with the
  document count, following the used_fixture_data precedent.

Two existing defects found and documented while designing this:
  - ai-ingest takes PGUSER=agent_ro from api.env, a SELECT-only role, so the
    Phase 3 command in the README cannot write doc_chunks (16.1).
  - ingest_file() always inserts superseded = FALSE, so `--all` re-ingests a
    superseded revision as live. --supersede survives only until the next bulk
    run (16.10).

One commit rather than three: the upload, withdrawal and pool designs
interleave in the same spec, README and compose files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:17:38 +10:00
Claude
0594fac3fb Fix four defects in langfuse-compose found deploying Phase 2
Bind address, and the reason this matters most. Langfuse is a Next.js
standalone server: it binds to whatever $HOSTNAME resolves to, not to all
interfaces. The container is on two networks and its hostname resolved to the
proxy address, so it listened on proxy only - HTTP 200 from proxy, HTTP 000
from ai-internal. Caddy is on proxy, so the public URL worked perfectly and
nothing looked wrong, but ai-api runs on ai-internal and every trace it sent
would have failed silently in Phase 6. HOSTNAME=0.0.0.0 fixes it; both
networks now return 200.

External network name. ai-internal does not exist - Compose created it as
azureuser_ai-internal under the host's shared project name. Verified both ways
on the host: with name: a container joins and reaches pg-ai, without it up
fails with "network ai-internal declared as external, but could not be found".

Healthcheck. Was probing localhost, which failed for the bind reason above and
marked the container unhealthy while it served fine. Restored to 127.0.0.1,
which now works because the bind is genuinely fixed rather than worked around.

Signup policy. AUTH_DISABLE_SIGNUP false. Langfuse keeps its own user table
behind Authelia; closed signup with no seeded user means nobody can log in at
all. Accepted for the PoC because Authelia is the real gate - revisit before
handover.

Phase 2 gate passed on the host with these applied: 302 to the auth portal,
certificate issued, AD + Duo login confirmed, and a test trace verified in the
database rather than trusted from the API response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:38:04 +10:00
Claude
12439fa387 Close two Phase 1 gate failures found on lin001
agent_ro could still create temporary tables: Postgres grants TEMP on every
database to PUBLIC, and revoking it from the role alone does not remove what
PUBLIC grants. Revoke it from PUBLIC too. postgres is a superuser and keeps
its temp tables, so the seed load is unaffected.

Nothing but the superuser could read schema fixture: 003_roles.sql grants only
on schema public, and the fixture schema is created afterwards. Grant read to
agent_ro and cube_rw. The grants live in 002_fixtures.sql because that file
opens with DROP SCHEMA fixture CASCADE, which would destroy grants held
anywhere else on every fixture reload.

Both re-verified on pg-ai: 11/11 privilege tests and 10/10 fixture access
tests pass, reads work for both roles, writes are rejected for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:02:51 +10:00
Claude
4826255672 Correct the spec against the host as actually observed
Verified on lin001 over SSH on 2026-08-20, read-only:

- Port 502 is bound to 10.0.0.17, not 0.0.0.0, so unauthenticated Modbus is
  not internet-reachable at the Docker level. This was an open risk in §3 and
  an unchecked item in §15; it is now a confirmation. openplc-runtime also
  publishes 8443 (the Runtime web UI) on the same private address, which the
  spec did not mention.
- openplc-runtime is not the only published port on the host: caddy, wireguard,
  mosquitto and chirpstack-gateway-bridge all publish on 0.0.0.0. The
  no-published-ports rule still applies in full to what we build, but the
  "one deliberate exception" framing was wrong and invited over-reading.
- Port 22 is open to the internet. Added to §15 as an open item for the same
  NSG review.
- 21 containers running, not 22; no stopped containers.
- /datadisk is 46% used with InfluxDB at 55 GB, up from 43%/52 GB. Recorded
  the growth rate so it can be budgeted for.

CLAUDE.md restates two of these rules and is updated to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:29:41 +10:00
Claude
60555e0885 Normalise line endings to LF
Authored on Windows, deployed to Ubuntu: a CRLF shell script fails on the
host with an unhelpful error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 13:56:43 +10:00
Claude
34d2ccc576 Scaffold the WRPS plant operations assistant repository
Build spec and host brief carried in from C:\Claude and WRPS/02-env; the
plant model (equipment, tags, alarm bitmask, enums, unit conversions) is
derived from WRPS/04-plc/register-map.csv, WRPS/05-scada/modbus/scada-points.csv
and WRPS-CTL-003.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 13:56:32 +10:00