@astram/guide 0.2.25 → 0.2.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/CAPABILITIES.md +120 -7
  2. package/package.json +1 -1
package/CAPABILITIES.md CHANGED
@@ -331,7 +331,26 @@ imports: [
331
331
  with the call's `batchId` and the row's `batchIndex` (ADR-0039). Inside `transaction(fn)` it
332
332
  joins the outer transaction.
333
333
  - `transaction(fn)` runs several pipe calls in one unit of work (rolls back on any failed write);
334
- `lockForUpdate(typeKey, id)` serialises a check-then-write.
334
+ `lockForUpdate(typeKey, id)` serialises a check-then-write: lock first, then re-read and
335
+ re-check, then write. It locks only a row you could `update` (your tenant, your `readScope`
336
+ subtree); a foreign or out-of-scope row is never locked.
337
+ - **Behaviour change (engine 0.19):** before this version a missing, foreign or out-of-scope id
338
+ was silently not locked. From this version `lockForUpdate` on such an id throws the same deny
339
+ an `update` of that id gets (`AuthorizationDeniedError`, reason `no_record_permit`; one answer
340
+ for all three) and locks nothing. Code that locks first and checks for `null` after must
341
+ either check existence first (`get` inside the transaction, then `lockForUpdate`) or catch
342
+ that deny. Prefer catching the deny when the re-check after the lock must see the latest
343
+ committed state: a plain read before the lock fixes the transaction's read snapshot before
344
+ the lock wait. The deny comes before any lock statement, so a caught deny leaves the
345
+ transaction usable: you may continue and commit. An `EngineUnavailableError` from it (the engine's
346
+ lock-probe pool did not answer within 2000 ms) is different: let it propagate (503); no lock
347
+ was taken.
348
+ - A row another transaction inserted but has not committed yet is "not found" (it used to wait
349
+ for it). If a row is deleted between the existence check and the lock, the lock briefly holds
350
+ the empty key range around that id until your transaction ends (nothing is revealed).
351
+ - **Read lock pattern:** to hold rows you only read, call `tx.authorize(typeKey, id, 'read')`
352
+ inside `transaction(fn)` (a locking read of the row as you may see it), taking ids in sorted
353
+ order to avoid deadlocks. Like `lockForUpdate`, it never locks a foreign or out-of-scope row.
335
354
  - `@Field({ immutable: true })` create-only fields; `@Field({ nullable: true })` fields that
336
355
  create, update and `createMany` may set to NULL (`json` included: SQL NULL, not `"null"`;
337
356
  the audit row lists the cleared keys, never values; the column must be NULL-able in your
@@ -629,7 +648,8 @@ upload(@Req() req: Record<string, unknown>) {
629
648
  only lower it. A route naming a profile the app did not register aborts boot. Needs
630
649
  `@nestjs/platform-express` (an optional peer; the scaffold has it).
631
650
  - **Profiles** — turn them on with `ShieldModule.forRoot({ fileProfiles: ['xlsx', 'csv', 'pdf', 'docx'] })`
632
- (default `['xlsx']`; an unknown, repeated or empty list aborts boot). You cannot define your own.
651
+ (default `['xlsx']`; an unknown, repeated or empty list aborts boot). For a format of your own, see
652
+ **App profiles** below.
633
653
  | Profile | Default cap | Declared types accepted (stored as the first) | Content check |
634
654
  |---|---|---|---|
635
655
  | `xlsx` | 10 MiB | spreadsheetml `.sheet` | ZIP + `[Content_Types].xml` names a spreadsheetml main part |
@@ -653,6 +673,46 @@ upload(@Req() req: Record<string, unknown>) {
653
673
  `xl/embeddings/` (a Word chart keeps its data there); they count toward the limits and are never
654
674
  opened. Files Office, LibreOffice, Google and common zip libraries write pass; a file with bytes
655
675
  added after the zip's end record (some mail gateways pad attachments) is refused.
676
+ - **App profiles (`appFileProfiles`, DP1)** — a format you own (a vendor export, a `.json` record,
677
+ a domain CSV dialect) is registered next to the shipped profiles and goes through the same
678
+ intake (cap, type, extension, your `magic`, sha256, tenant key) and `FilesService`:
679
+ ```ts
680
+ ShieldModule.forRoot({
681
+ storage: 'env',
682
+ fileProfiles: ['xlsx'],
683
+ appFileProfiles: [
684
+ {
685
+ name: 'inspection_json',
686
+ profile: {
687
+ maxBytes: 2 * 1024 * 1024,
688
+ contentTypes: ['application/json'], // first = the stored type
689
+ extensions: ['.json'],
690
+ magic: isInspectionJson, // your content check over the raw bytes; false or a throw = 415
691
+ },
692
+ },
693
+ ],
694
+ });
695
+ // route: @UseInterceptors(FileIntake({ field: 'file', profile: 'inspection_json' }))
696
+ ```
697
+ Boot refuses (naming the entry index and rule): more than 16 entries; a name not
698
+ `^[a-z][a-z0-9_]{0,31}$` or equal to a shipped name or repeated; `magic` not a function;
699
+ `maxBytes` not a positive integer ≤ 50 MiB; a content type that is not a plain lower-case
700
+ `type/subtype` (no parameters; stricter than the spec, so a malformed value can never reach a
701
+ download header), that is denied, or that a shipped profile (registered or not) or another app
702
+ profile already uses; an extension not `^\.[a-z0-9]{1,10}$` or already used. **Denied types**
703
+ (`APP_CONTENT_TYPE_DENY_LIST` + `isDeniedAppContentType` in `@astram/guard`; wider than the spec):
704
+ `text/html`, `application/octet-stream`; xml (`application/xml`, `text/xml`, and any `+xml`
705
+ subtype such as `image/svg+xml`, `application/xhtml+xml`); php; `application/wasm`; shell and
706
+ script types (`sh`, `csh`, `bash`, `powershell`, python, perl, ruby, `bat`, DOS programs); and
707
+ every javascript / ecmascript / jscript variant. **Cap:** your profile's `maxBytes` is its
708
+ ceiling. An explicit `OBJECT_STORAGE_MAX_BYTES` can only **lower** it, never raise it (stricter
709
+ than the spec; shipped profiles differ: the env value replaces their cap). Downloads serve the
710
+ app type with the same `attachment` + `nosniff` headers.
711
+ **ZIP-based formats:** your `magic` MUST call `zipWithinLimits(bytes)` (from `@astram/core`)
712
+ before reading anything else; it applies every archive limit above without inflating anything.
713
+ For another OOXML format, `ooxmlMagic('<main part content type>')` is the shipped xlsx/docx check.
714
+ The framework never parses app-profile bytes and the file analyser never receives them (reading
715
+ them is DP2 or your own isolated worker).
656
716
  - **`FilesService`** (provided by `IdentityFeatureModule`) — `store(intake)`, `open(uuid)`,
657
717
  `remove(uuid)`. No actor argument: tenant, owner and audit actor come from the session. `store`
658
718
  puts the object at `t/<tenant>/<uuid>`, then creates the record (gate, tenant stamp, audit); a
@@ -729,10 +789,11 @@ upload(@Req() req: Record<string, unknown>) {
729
789
  - **Set `POOL_MAX_ENGINE`** (e.g. 10). Unset, the engine pool uses `APP_DB_MAX_CONNECTIONS`, and
730
790
  the single-layout budget `workers × (POOL_MAX_CORE + POOL_MAX_ENGINE + APP_AUTHZ_POOL_MAX) +
731
791
  headroom` fails boot. `APP_DB_MAX_USER_CONNECTIONS` (the account's cap, if the host sets one) is
732
- checked too. The budget also counts 1 connection per worker for the audit sealer's own pool.
792
+ checked too. The budget also counts 1 connection per worker for the audit sealer's own pool and
793
+ 2 per worker for the engine's lock-probe pool.
733
794
  - **Set `POOL_WORKERS` and `APP_DB_MAX_CONNECTIONS` too** (your real worker count and what the
734
795
  server gives you). Their defaults (8 workers, 250) describe a dedicated four-database server; on
735
- them the single-layout budget is 8 × 27 + 40 = 256 > 250 and boot fails.
796
+ them the single-layout budget is 8 × 29 + 40 = 272 > 250 and boot fails.
736
797
  - **Idle connections and timeouts (G-026, both layouts):** the `POOL_MAX_*` values are caps. Idle,
737
798
  every framework pool shrinks to `POOL_MIN_IDLE` (default 1) after `POOL_IDLE_TIMEOUT_S` (default
738
799
  600), so an idle process holds about 4 connections. Optional `APP_DB_QUERY_TIMEOUT_MS`
@@ -830,7 +891,8 @@ upload(@Req() req: Record<string, unknown>) {
830
891
  `AGENT_RUNTIME_URL` (`https:`, or `http:` in development/test or to a loopback host),
831
892
  `AGENT_RUNTIME_KEY_ID`, `AGENT_RUNTIME_KEY` (`secret-ref:` outside development/test).
832
893
  - **Who may use it.** Keys `identity:agent:chat` (use it), `identity:agent:restricted_data` (send
833
- `restricted` data to an approved provider), `identity:agent:propose` (M3). A tenant also needs
894
+ `restricted` data to an approved provider), `identity:agent:propose` (M3: `propose` capabilities,
895
+ see Proposals below). A tenant also needs
834
896
  mode `synthetic` (org bootstrap `agent: { mode }`); each company is set on its own. `identity:*`
835
897
  holders get all three keys, including `restricted_data` (GAPS #23): grant `identity:agent:chat`
836
898
  explicitly on production roles.
@@ -877,8 +939,59 @@ upload(@Req() req: Record<string, unknown>) {
877
939
  masking (not built yet) is a prerequisite before real data.
878
940
  - Totals the assistant states come from `profile_sheet` or `aggregate`, never from adding rows it read.
879
941
  Ids inside file content reach the model as `[id]`.
880
- - Not yet: the attach control in the scaffold UI (the client service supports `attachments`), the
881
- workbook map (D2b), marking an upload `restricted`.
942
+ - **Workbook structure (G-037 G1).** Three more framework tools for xlsx and csv, for sheets that are
943
+ not "row 1 is the header" tables (matrix, transposed, stacked blocks, merged headers):
944
+ `files.list_sheets@1` (each sheet's state, computed used range, hidden rows and columns, counts of
945
+ merges, tables, charts, names, validations and formulas; a sheet over the 1,000,000-cell scan cap is
946
+ listed with `scanned: false`), `files.read_grid@1` (raw cells with A1 addresses for a range the model
947
+ picks, at most 400 cells and 16 KiB a call; `truncated.nextRange` gives the rest; a formula cell has
948
+ `f`: `x` external, `v` volatile, `f` other, and `v` is its cached value, never evaluated) and
949
+ `files.read_structure@1` (merges, formula groups as R1C1 shapes with string literals replaced by `"…"`,
950
+ tables, defined names, list validations, chart series refs; `parts`, `range`, `offset`). They return
951
+ structure only: deciding what a block means is your code's job (a consumer step, not a framework one).
952
+ An analyser older than the app is refused at boot (`analyser_version_skew`): upgrade the analyser image
953
+ together with `@astram/agent`. App code gets the same three reads, unmasked, on `FileAnalyser`
954
+ (`outlineWorkbook`, `readGrid`, `readStructure`).
955
+ - **Masking of these results, and its limits.** Like every `files.*` result they are masked before the
956
+ model sees them. `read_grid` has no header row, so the analyser marks each cell covered by a header hint
957
+ (`p: <kind>`, from the column or transposed row of a header such as `Name` or `Email`, even when the
958
+ header lies outside the range read) and the cell is masked whole. Emails, phones and the other built-in
959
+ kinds are masked wherever they appear, including table column names, chart titles, defined-name text
960
+ and validation lists. **Person names have no detector.** A name that no header hint covers reaches the
961
+ model unmasked: a table column that is itself a name, a chart title or series name, the text of a
962
+ defined name, a validation list outside a hinted column. Put the data the model must not see behind a
963
+ hinted header, or keep such files out of a conversation until your own detector (`defineDetector`)
964
+ covers them.
965
+ - **Disclosed over-mask.** The hint rule can mask more than the personal data: a leftmost header cell
966
+ that is itself a hint (`Name`) masks the other header labels of that row, and in a stacked sheet a
967
+ later block's `Name` label and the values under an earlier block's hinted column are masked too. This
968
+ fails safe (values are never shown); the model may need to ask for the labels by position.
969
+ - Not yet: the attach control in the scaffold UI (the client service supports `attachments`), marking
970
+ an upload `restricted`. The earlier "workbook map" design (D2b) was replaced by the three tools above.
971
+ - **Proposals (G-036 M3, ADR-0049 M3 addendum).** The assistant never writes records; it may
972
+ **propose**, and a person applies.
973
+ - Declare what a change means with `defineProposalKind({ id, version, label, payloadSchema,
974
+ targetTypes: [oneAppType], keys: { propose, review, apply }, plan, validate?, preview?,
975
+ distinctApprover?, maxItems?, expiryMs? })` (`@astram/identity`) and pass it to
976
+ `IdentityFeatureModule.forRoot({ proposalKinds })`. `plan` turns a payload into `create` /
977
+ `update` items on that one type; `plan` and `validate` get a read-only `ctx.records`. There is no
978
+ apply handler: the framework writes the approved items through the record pipe as the approver,
979
+ in one transaction, with read-back. The three keys are custom actions on an app type (ADR-0038).
980
+ - A capability with `effect: 'propose'`, `proposes: [kindIds]` and `requires` including
981
+ `identity:agent:propose` returns a draft `{ kind, payload, title, rationale?, metadata? }`; the
982
+ framework submits it as the user. The model gets a handle and counts; the browser gets
983
+ `approval.required` (`AgentTaskState.approvals` in `@astram/frontend-core`). Workers cannot propose.
984
+ - To let a capability see proposals, declare `readsProposalKinds: [kindIds]` with
985
+ `identity:proposal:read` in `requires` and call `ctx.proposals.list({ kind, state?, page?,
986
+ pageSize? })` (≤ 20 a page, the same scope as `GET /identity/proposals`, handles and labels only).
987
+ - **Never read or write the `proposal` / `proposal_item` rows yourself**: the engine refuses every
988
+ `get`, `list`, `aggregate`, lock and write on them outside the framework's own lifecycle operation,
989
+ whatever keys the user holds. Use `/identity/proposals` (review UI: `createProposalService` in
990
+ `@astram/frontend-core`; render values as text and never display ids) or the capability reader.
991
+ - By default the requester cannot approve or apply their own proposal; `distinctApprover: false`
992
+ opts out and adds a caution to the kind's `review` / `apply` permission text. Terminal proposals
993
+ are deleted after `PROPOSAL_RETENTION_DAYS` (365, 30–3650); their audit rows stay.
994
+ - Not yet: a review page in the scaffolded template.
882
995
 
883
996
  ## Available, but NOT wired to a data path (do not assume they protect data yet)
884
997
  - **Envelope encryption / per-tenant DEKs** (`createEncryptionService`, `LocalKekCrypto`): the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astram/guide",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
4
4
  "license": "MIT",
5
5
  "description": "Astram framework — the AI/dev GUIDE layer (ADR-0025). Ships the framework's hard rules + capabilities manifest as installed, upgrade-refreshed docs. A consumer app's CLAUDE.md references FRAMEWORK_RULES.md from this package; `npm update` replaces it wholesale so new rules arrive cleanly, while the app's own .astram/guide/ layer is never touched. This is the framework-owned FLOOR: an app rule may tighten it, never weaken it.",
6
6
  "type": "commonjs",