@astram/guide 0.2.25 → 0.2.27

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 +174 -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
@@ -688,6 +748,51 @@ upload(@Req() req: Record<string, unknown>) {
688
748
  - Not provided: malware scanning, per-tenant encryption of file bytes (SSE-S3 only), streaming
689
749
  downloads, dedup (each upload is its own record; `sha256` is returned so you can detect repeats).
690
750
 
751
+ ### Sharing an attached file with a role (`SHIELD_FILE_SHARE`, guard 0.33, identity 0.32; ADR-0040 addendum)
752
+ Use this when users of a role in the same company must read a file attached to one of your records
753
+ ("the stewards of a source read its revision file"). Never write `record_share` yourself and never
754
+ grant a tenant-wide `stored_file` rule for this.
755
+ 1. **Declare the attachment field** on your parent type: `@Field({ type: 'uuid', references:
756
+ 'stored_file' })`. The type must be tenant-walled (a `trusted: 'tenantId'` field), data plane,
757
+ not global, not `operationReads`, with a uuid primary key, and the field must not be `trusted`.
758
+ A type that declares such a field but breaks a rule fails boot naming the type and field; boot
759
+ logs one line `file-share parents: <type> (<fields>)`.
760
+ 2. **Inject the port** in an app service (not an agent capability: that fails agent boot,
761
+ `inject_framework`):
762
+ ```ts
763
+ import { Inject } from '@nestjs/common';
764
+ import { SHIELD_FILE_SHARE, FileShareError, type FileSharePort } from '@astram/guard';
765
+ constructor(@Inject(SHIELD_FILE_SHARE) private readonly fileShare: FileSharePort) {}
766
+ const r = await this.fileShare.shareWithRole(fileId, roleId, { typeKey: 'source', id: sourceId });
767
+ // r = { created, effective, missing }
768
+ ```
769
+ It is request-scoped: actor and tenant come from the session, no argument names them. The caller
770
+ must be able to `update` the parent and read the file itself; the parent must reference the file
771
+ and the role must belong to the same company. Each call runs in one transaction (it joins yours;
772
+ a refusal then rolls yours back).
773
+ 3. **The role still needs `identity:stored_file:read`.** The port never grants it. Without it the
774
+ share is written and `shareWithRole` returns `{ effective: false, missing:
775
+ ['identity:stored_file:read'] }`; it works with no further call once the key is granted.
776
+ 4. **Revoke in the same transaction as the detach, deactivate or delete.** Call
777
+ `unshare(fileId, roleId, parent)` when you detach a file (it works after the detach) and
778
+ `revokeForParent(parent)` before you deactivate or delete the parent. A share outlives its reason
779
+ if you skip this. After a parent is already deleted, `revokeForParent` still works for a caller
780
+ holding the parent type's `delete` key. `reconcileFile(fileId)` removes the file's port rows
781
+ whose parent is gone, no longer references it or is no longer eligible (any user who can read
782
+ the file); `listForParent(parent)` returns `{ fileId, roleId, createdAtMs }[]`. Those ids are
783
+ for linking only: resolve them to labels (role name, file display name) before showing them,
784
+ and never show a raw id in the UI.
785
+ - **Errors** (`FileShareError.code`, fixed messages, `httpStatus`): `file_share_invalid` 400
786
+ (malformed id, ineligible parent type), `file_share_not_found` 404 (any missing, hidden, foreign
787
+ or unattached id; root or tenantless caller), `file_share_denied` 403 (caller reads but may not
788
+ update the parent), `file_share_unavailable` 503 (database or audit outage; rolled back).
789
+ - **Audit and owner view.** Each change writes `share.created` / `share.removed` with operation
790
+ `shield:file-share` (`FILE_SHARE_OPERATION` from `@astram/identity`), step, parent type and id,
791
+ never the file name. The file owner (or a user `decide()` allows to `update` the file) sees these
792
+ rows in `GET /identity/record-shares` as `via: <parent type label>`, `removable: false`; deleting
793
+ one there answers 403 `This share is managed by a linked record.`
794
+ - Run `astram-migrate` before deploying (guard migration 126 on `record_share`); no env change.
795
+
691
796
  ## Database names and several environments on one server (`SHIELD_DB_NAMESPACE`, ADR-0037)
692
797
  - The framework uses four databases, `<ns>_app`, `<ns>_identity`, `<ns>_authz` and `<ns>_audit`,
693
798
  with its own accounts (`<ns>_core`, `<ns>_migrate`, `<ns>_audit_ops`, …). If you leave
@@ -729,10 +834,11 @@ upload(@Req() req: Record<string, unknown>) {
729
834
  - **Set `POOL_MAX_ENGINE`** (e.g. 10). Unset, the engine pool uses `APP_DB_MAX_CONNECTIONS`, and
730
835
  the single-layout budget `workers × (POOL_MAX_CORE + POOL_MAX_ENGINE + APP_AUTHZ_POOL_MAX) +
731
836
  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.
837
+ checked too. The budget also counts 1 connection per worker for the audit sealer's own pool and
838
+ 2 per worker for the engine's lock-probe pool.
733
839
  - **Set `POOL_WORKERS` and `APP_DB_MAX_CONNECTIONS` too** (your real worker count and what the
734
840
  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.
841
+ them the single-layout budget is 8 × 29 + 40 = 272 > 250 and boot fails.
736
842
  - **Idle connections and timeouts (G-026, both layouts):** the `POOL_MAX_*` values are caps. Idle,
737
843
  every framework pool shrinks to `POOL_MIN_IDLE` (default 1) after `POOL_IDLE_TIMEOUT_S` (default
738
844
  600), so an idle process holds about 4 connections. Optional `APP_DB_QUERY_TIMEOUT_MS`
@@ -830,7 +936,8 @@ upload(@Req() req: Record<string, unknown>) {
830
936
  `AGENT_RUNTIME_URL` (`https:`, or `http:` in development/test or to a loopback host),
831
937
  `AGENT_RUNTIME_KEY_ID`, `AGENT_RUNTIME_KEY` (`secret-ref:` outside development/test).
832
938
  - **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
939
+ `restricted` data to an approved provider), `identity:agent:propose` (M3: `propose` capabilities,
940
+ see Proposals below). A tenant also needs
834
941
  mode `synthetic` (org bootstrap `agent: { mode }`); each company is set on its own. `identity:*`
835
942
  holders get all three keys, including `restricted_data` (GAPS #23): grant `identity:agent:chat`
836
943
  explicitly on production roles.
@@ -877,8 +984,68 @@ upload(@Req() req: Record<string, unknown>) {
877
984
  masking (not built yet) is a prerequisite before real data.
878
985
  - Totals the assistant states come from `profile_sheet` or `aggregate`, never from adding rows it read.
879
986
  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`.
987
+ - **Workbook structure (G-037 G1).** Three more framework tools for xlsx and csv, for sheets that are
988
+ not "row 1 is the header" tables (matrix, transposed, stacked blocks, merged headers):
989
+ `files.list_sheets@1` (each sheet's state, computed used range, hidden rows and columns, counts of
990
+ merges, tables, charts, names, validations and formulas; a sheet over the 1,000,000-cell scan cap is
991
+ listed with `scanned: false`), `files.read_grid@1` (raw cells with A1 addresses for a range the model
992
+ picks, at most 400 cells and 16 KiB a call; `truncated.nextRange` gives the rest; a formula cell has
993
+ `f`: `x` external, `v` volatile, `f` other, and `v` is its cached value, never evaluated) and
994
+ `files.read_structure@1` (merges, formula groups as R1C1 shapes with string literals replaced by `"…"`,
995
+ tables, defined names, list validations, chart series refs; `parts`, `range`, `offset`). They return
996
+ structure only: deciding what a block means is your code's job (a consumer step, not a framework one).
997
+ An analyser older than the app is refused at boot (`analyser_version_skew`): upgrade the analyser image
998
+ together with `@astram/agent`. App code gets the same three reads, unmasked, on `FileAnalyser`
999
+ (`outlineWorkbook`, `readGrid`, `readStructure`).
1000
+ - **Masking of these results, and its limits.** Like every `files.*` result they are masked before the
1001
+ model sees them. `read_grid` has no header row, so the analyser marks each cell covered by a header hint
1002
+ (`p: <kind>`, from the column or transposed row of a header such as `Name` or `Email`, even when the
1003
+ header lies outside the range read) and the cell is masked whole. Emails, phones and the other built-in
1004
+ kinds are masked wherever they appear, including table column names, chart titles, defined-name text
1005
+ and validation lists. **Person names have no detector.** A name that no header hint covers reaches the
1006
+ model unmasked: a table column that is itself a name, a chart title or series name, the text of a
1007
+ defined name, a validation list outside a hinted column. Put the data the model must not see behind a
1008
+ hinted header, or keep such files out of a conversation until your own detector (`defineDetector`)
1009
+ covers them.
1010
+ - **Disclosed over-mask.** The hint rule can mask more than the personal data: a leftmost header cell
1011
+ that is itself a hint (`Name`) masks the other header labels of that row, and in a stacked sheet a
1012
+ later block's `Name` label and the values under an earlier block's hinted column are masked too. This
1013
+ fails safe (values are never shown); the model may need to ask for the labels by position.
1014
+ - Not yet: the attach control in the scaffold UI (the client service supports `attachments`), marking
1015
+ an upload `restricted`. The earlier "workbook map" design (D2b) was replaced by the three tools above.
1016
+ - **Proposals (G-036 M3, ADR-0049 M3 addendum).** The assistant never writes records; it may
1017
+ **propose**, and a person applies.
1018
+ - Declare what a change means with `defineProposalKind({ id, version, label, payloadSchema,
1019
+ targetTypes: [oneAppType], keys: { propose, review, apply }, plan, validate?, preview?,
1020
+ distinctApprover?, maxItems?, expiryMs?, screen?, screenCodes?, submit? })` (`@astram/identity`)
1021
+ and pass it to `IdentityFeatureModule.forRoot({ proposalKinds })`. `plan` turns a payload into
1022
+ `create` / `update` items on that one type; `plan` and `validate` get a read-only `ctx.records`.
1023
+ There is no apply handler: the framework writes the approved items through the record pipe as the
1024
+ approver, in one transaction, with read-back. The three keys are custom actions on an app type
1025
+ (ADR-0038).
1026
+ - To check a draft before anything is stored (for example personal data in a label), give the kind
1027
+ `screen({ payload, title, rationale, metadata }, ctx)` and the codes it may refuse with in
1028
+ `screenCodes`. It runs on every submit path (the HTTP route, your own code through
1029
+ `SHIELD_PROPOSAL_PORT`, the assistant). Return `{ refuse: '<code>' }` to refuse: the caller gets
1030
+ 422 `screened` with that code, and no proposal, audit row or notice is written. A throw or an
1031
+ undeclared code refuses too (`screen_failed`). Do not screen in `validate`: its refusals are stored.
1032
+ `submit: 'app-only'` stops the generic `POST /identity/proposals` route for that kind, so only your
1033
+ own routes (through `SHIELD_PROPOSAL_PORT`) and the assistant can submit it.
1034
+ - A capability with `effect: 'propose'`, `proposes: [kindIds]` and `requires` including
1035
+ `identity:agent:propose` returns a draft `{ kind, payload, title, rationale?, metadata? }`; the
1036
+ framework submits it as the user. The model gets a handle and counts; the browser gets
1037
+ `approval.required` (`AgentTaskState.approvals` in `@astram/frontend-core`). Workers cannot propose.
1038
+ - To let a capability see proposals, declare `readsProposalKinds: [kindIds]` with
1039
+ `identity:proposal:read` in `requires` and call `ctx.proposals.list({ kind, state?, page?,
1040
+ pageSize? })` (≤ 20 a page, the same scope as `GET /identity/proposals`, handles and labels only).
1041
+ - **Never read or write the `proposal` / `proposal_item` rows yourself**: the engine refuses every
1042
+ `get`, `list`, `aggregate`, lock and write on them outside the framework's own lifecycle operation,
1043
+ whatever keys the user holds. Use `/identity/proposals` (review UI: `createProposalService` in
1044
+ `@astram/frontend-core`; render values as text and never display ids) or the capability reader.
1045
+ - By default the requester cannot approve or apply their own proposal; `distinctApprover: false`
1046
+ opts out and adds a caution to the kind's `review` / `apply` permission text. Terminal proposals
1047
+ are deleted after `PROPOSAL_RETENTION_DAYS` (365, 30–3650); their audit rows stay.
1048
+ - Not yet: a review page in the scaffolded template.
882
1049
 
883
1050
  ## Available, but NOT wired to a data path (do not assume they protect data yet)
884
1051
  - **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.27",
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",